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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/lib/smplkit/account/client.rb +128 -0
  3. data/lib/smplkit/account/models.rb +71 -0
  4. data/lib/smplkit/api_support.rb +91 -0
  5. data/lib/smplkit/audit/buffer.rb +3 -1
  6. data/lib/smplkit/audit/categories.rb +21 -10
  7. data/lib/smplkit/audit/client.rb +18 -9
  8. data/lib/smplkit/audit/event_types.rb +26 -10
  9. data/lib/smplkit/audit/events.rb +93 -17
  10. data/lib/smplkit/{management/audit.rb → audit/forwarders.rb} +93 -85
  11. data/lib/smplkit/audit/models.rb +86 -32
  12. data/lib/smplkit/audit/resource_types.rb +21 -9
  13. data/lib/smplkit/buffers.rb +250 -0
  14. data/lib/smplkit/client.rb +161 -70
  15. data/lib/smplkit/config/client.rb +874 -186
  16. data/lib/smplkit/config/helpers.rb +44 -6
  17. data/lib/smplkit/config/models.rb +114 -7
  18. data/lib/smplkit/config_resolution.rb +17 -9
  19. data/lib/smplkit/errors.rb +14 -3
  20. data/lib/smplkit/flags/client.rb +602 -116
  21. data/lib/smplkit/flags/models.rb +110 -8
  22. data/lib/smplkit/flags/types.rb +8 -9
  23. data/lib/smplkit/jobs/client.rb +306 -0
  24. data/lib/smplkit/jobs/models.rb +47 -18
  25. data/lib/smplkit/logging/client.rb +755 -191
  26. data/lib/smplkit/logging/helpers.rb +5 -1
  27. data/lib/smplkit/logging/levels.rb +3 -1
  28. data/lib/smplkit/logging/models.rb +163 -6
  29. data/lib/smplkit/logging/normalize.rb +3 -1
  30. data/lib/smplkit/logging/resolution.rb +4 -4
  31. data/lib/smplkit/logging/sources.rb +1 -1
  32. data/lib/smplkit/platform/client.rb +597 -0
  33. data/lib/smplkit/platform/models.rb +282 -0
  34. data/lib/smplkit/{management → platform}/types.rb +21 -4
  35. data/lib/smplkit/transport.rb +103 -0
  36. data/lib/smplkit/ws.rb +1 -1
  37. data/lib/smplkit.rb +18 -6
  38. metadata +11 -7
  39. data/lib/smplkit/management/buffer.rb +0 -198
  40. data/lib/smplkit/management/client.rb +0 -1074
  41. data/lib/smplkit/management/jobs.rb +0 -226
  42. data/lib/smplkit/management/models.rb +0 -178
@@ -1,12 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "concurrent"
4
3
  require "json"
5
4
  require "digest"
6
5
 
6
+ # The Smpl Flags client — one unified +FlagsClient+.
7
+ #
8
+ # Smpl Flags has two surfaces on a single client, mirroring how the config,
9
+ # audit, and jobs clients expose their full surface from one class:
10
+ #
11
+ # * *CRUD surface* — pure CRUD, no live connection: +new_boolean_flag+ /
12
+ # +new_string_flag+ / +new_number_flag+ / +new_json_flag+ constructors, +get+
13
+ # / +list+ / +delete+ CRUD, and the flag-declaration discovery buffer
14
+ # (+register+ / +flush+ / +flush_sync+ / +pending_count+). The client owns the
15
+ # discovery buffer directly.
16
+ # * *Live surface* — lazily connects to your running service on first use: the
17
+ # typed handle declarations (+boolean_flag+ / +string_flag+ / +number_flag+ /
18
+ # +json_flag+) whose +.get+ evaluates against the cached definitions, plus
19
+ # +refresh+ / +stats+ / +on_change+. The first live call transparently flushes
20
+ # discovery, fetches all flag definitions into the local cache, and opens the
21
+ # live-updates WebSocket — no explicit install step.
22
+ #
23
+ # The client supports two construction shapes:
24
+ #
25
+ # * *Wired* into +Smplkit::Client+ — borrows the parent's flags transport for
26
+ # both runtime fetch and CRUD, the parent's shared WebSocket for the live
27
+ # channel, and +client.platform.contexts+ for evaluation-context
28
+ # registration. This is the common path.
29
+ # * *Standalone* — +FlagsClient.new(api_key: ..., base_url: ..., ...)+ builds
30
+ # and owns its own flags transport and a contexts client (against its own app
31
+ # transport), and on first live use opens and owns its own WebSocket. +close+
32
+ # tears down only the owned transports and owned WebSocket.
7
33
  module Smplkit
8
34
  module Flags
9
35
  # Describes a flag definition change. Frozen — fields are set at construction.
36
+ #
37
+ # @!attribute [r] id
38
+ # @return [String] id of the flag whose definition changed.
39
+ # @!attribute [r] source
40
+ # @return [String] origin of the change (e.g. +"websocket"+ for a live
41
+ # update or +"manual"+ for a refresh).
42
+ # @!attribute [r] deleted
43
+ # @return [Boolean] whether the change was a deletion of the flag.
10
44
  class FlagChangeEvent
11
45
  attr_reader :id, :source, :deleted
12
46
 
@@ -28,6 +62,8 @@ module Smplkit
28
62
  end
29
63
 
30
64
  # Thread-safe LRU resolution cache with hit/miss stats.
65
+ #
66
+ # @api private
31
67
  class ResolutionCache
32
68
  DEFAULT_MAX_SIZE = 10_000
33
69
 
@@ -41,6 +77,7 @@ module Smplkit
41
77
  @cache_misses = 0
42
78
  end
43
79
 
80
+ # Return [hit, value]. Moves the key to end on hit.
44
81
  def get(cache_key)
45
82
  @lock.synchronize do
46
83
  if @cache.key?(cache_key)
@@ -69,106 +106,385 @@ module Smplkit
69
106
  end
70
107
 
71
108
  # Evaluation statistics for the flags runtime.
109
+ #
110
+ # @!attribute [r] cache_hits
111
+ # @return [Integer] number of flag evaluations served from the local cache.
112
+ # @!attribute [r] cache_misses
113
+ # @return [Integer] number of flag evaluations that missed the cache and
114
+ # were evaluated from the flag definitions.
72
115
  FlagStats = Struct.new(:cache_hits, :cache_misses, keyword_init: true)
73
116
 
74
- # Synchronous flags runtime namespace.
117
+ # Convert a list of Context objects to the nested evaluation dict.
75
118
  #
76
- # Obtained via +Smplkit::Client#flags+. Exposes typed handles
77
- # (+boolean_flag+/+string_flag+/+number_flag+/+json_flag+) and runtime
78
- # control (+refresh+, +stats+, +on_change+). CRUD has moved to
79
- # +mgmt.flags.*+. Per-request context is set via
80
- # +client.set_context([...])+.
81
- class FlagsClient
82
- INITIAL_START_RETRY_DELAY = 1.0
83
- MAX_START_RETRY_DELAY = 60.0
119
+ # @api private
120
+ def self.contexts_to_eval_dict(contexts)
121
+ contexts.to_h { |ctx| [ctx.type, ctx.to_eval_hash] }
122
+ end
123
+
124
+ # Compute a stable hash for a context evaluation dict.
125
+ #
126
+ # @api private
127
+ def self.hash_context(eval_dict)
128
+ Digest::MD5.hexdigest(JSON.generate(deep_sort(eval_dict)))
129
+ end
130
+
131
+ # @api private
132
+ def self.deep_sort(value)
133
+ case value
134
+ when Hash
135
+ value.keys.sort_by(&:to_s).to_h { |k| [k, deep_sort(value[k])] }
136
+ when Array
137
+ value.map { |v| deep_sort(v) }
138
+ else
139
+ value
140
+ end
141
+ end
142
+
143
+ # Build standalone flags + app transports and resolve the app base URL.
144
+ #
145
+ # +base_url+/+api_key+ are used directly when supplied (the path a top-level
146
+ # client takes after it has already resolved them); otherwise the management
147
+ # config resolver fills in whatever is missing (+~/.smplkit+ / env vars /
148
+ # defaults). The app transport backs the standalone contexts client
149
+ # (evaluation-context registration); the app base URL is returned so a
150
+ # standalone client can open its own WebSocket against the event gateway.
151
+ #
152
+ # @api private
153
+ def self.flags_transport(api_key:, base_url:, profile:, base_domain:, scheme:, debug:, extra_headers:)
154
+ cfg = ConfigResolution.resolve_client_config(
155
+ profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme, debug: debug
156
+ )
157
+ resolved_key = api_key.nil? ? cfg.api_key : api_key
158
+ merged = {}
159
+ merged.merge!(cfg.extra_headers || {})
160
+ merged.merge!(extra_headers || {})
161
+ tcfg = ConfigResolution::ResolvedClientConfig.new(
162
+ api_key: resolved_key, base_domain: cfg.base_domain, scheme: cfg.scheme,
163
+ debug: cfg.debug, extra_headers: merged
164
+ )
165
+ app_url = ConfigResolution.service_url(cfg.scheme, "app", cfg.base_domain)
166
+ flags_http = Transport.build_api_client(SmplkitGeneratedClient::Flags, "flags", tcfg, base_url: base_url)
167
+ app_http = Transport.build_api_client(SmplkitGeneratedClient::App, "app", tcfg)
168
+ [flags_http, app_http, app_url, resolved_key]
169
+ end
84
170
 
85
- def initialize(parent, manage:, metrics:, flags_base_url:, app_base_url:)
171
+ # The Smpl Flags client (sync).
172
+ #
173
+ # One client exposes the full surface, reachable as +client.flags+
174
+ # (+Smplkit::Client+) or constructed directly:
175
+ #
176
+ # flags = Smplkit::FlagsClient.new(environment: "production")
177
+ # new_flag = flags.new_boolean_flag("beta", default: false)
178
+ # new_flag.save
179
+ # beta = flags.boolean_flag("beta", default: false)
180
+ # beta.get # => ...
181
+ #
182
+ # The CRUD surface (+new_*+ / +get+ / +list+ / +delete+ and discovery)
183
+ # is pure CRUD. The live surface (+boolean_flag+ / +string_flag+ /
184
+ # +number_flag+ / +json_flag+ / +refresh+ / +stats+ / +on_change+) connects
185
+ # lazily on first use — the first call flushes discovery, fetches all flag
186
+ # definitions into the local cache, and opens the live-updates WebSocket. No
187
+ # explicit install step is required.
188
+ class FlagsClient
189
+ def initialize(api_key = nil, environment: nil, base_url: nil, profile: nil,
190
+ base_domain: nil, scheme: nil, debug: nil, extra_headers: nil,
191
+ parent: nil, transport: nil, contexts: nil, metrics: nil)
86
192
  @parent = parent
87
- @manage = manage
88
193
  @metrics = metrics
89
- @service = parent._service
90
- @environment = parent._environment
91
- @flags_base_url = flags_base_url
92
- @app_base_url = app_base_url
194
+ @environment = parent.nil? ? environment : parent._environment
195
+ @service = parent&._service
196
+ @standalone_api_key = nil
197
+ if transport.nil?
198
+ @flags_http, app_http, @app_base_url, @standalone_api_key = Flags.flags_transport(
199
+ api_key: api_key, base_url: base_url, profile: profile,
200
+ base_domain: base_domain, scheme: scheme, debug: debug, extra_headers: extra_headers
201
+ )
202
+ # Standalone: build our own contexts client (and own its app transport).
203
+ @contexts = Platform::ContextsClient.new(app_http, ContextRegistrationBuffer.new)
204
+ else
205
+ @flags_http = transport
206
+ @app_base_url = nil
207
+ # Wired: borrow client.platform.contexts as the evaluation-context
208
+ # registration seam.
209
+ @contexts = contexts
210
+ end
211
+ @api = SmplkitGeneratedClient::Flags::FlagsApi.new(@flags_http)
212
+
213
+ # Discovery buffer is owned by this client (no management delegation).
214
+ @buffer = FlagRegistrationBuffer.new
93
215
 
216
+ # Live-surface state.
94
217
  @flag_store = {}
95
218
  @connected = false
96
219
  @ws_subscribed = false
97
- @next_start_attempt_at = 0.0
98
- @start_retry_delay = INITIAL_START_RETRY_DELAY
99
220
  @cache = ResolutionCache.new
100
221
  @handles = {}
101
222
  @global_listeners = []
102
223
  @key_listeners = Hash.new { |h, k| h[k] = [] }
103
224
  @ws_manager = nil
225
+ @owns_ws = false
104
226
  @lock = Mutex.new
105
227
  end
106
228
 
107
- def boolean_flag(id, default:)
108
- register_handle(BooleanFlag, id, "BOOLEAN", default)
229
+ # ----------------------------------------------------------------
230
+ # Management surface: CRUD (no live connection)
231
+ # ----------------------------------------------------------------
232
+
233
+ # Return a new unsaved boolean +BooleanFlag+. Call +save+ to persist.
234
+ #
235
+ # @param id [String] stable flag identifier, unique per account.
236
+ # @param default [Boolean] value served when no environment override or
237
+ # rule applies.
238
+ # @param name [String, nil] human-readable display name; defaults to a
239
+ # title-cased form of +id+.
240
+ # @param description [String, nil] optional free-text description of the flag.
241
+ # @return [BooleanFlag] an unsaved flag; call +save+ to persist it.
242
+ def new_boolean_flag(id, default:, name: nil, description: nil)
243
+ BooleanFlag.new(
244
+ self, id: id, name: name || Smplkit::Helpers.key_to_display_name(id),
245
+ type: "BOOLEAN", default: default,
246
+ values: [FlagValue.new(name: "True", value: true), FlagValue.new(name: "False", value: false)],
247
+ description: description
248
+ )
249
+ end
250
+
251
+ # Return a new unsaved string +StringFlag+. Call +save+ to persist.
252
+ #
253
+ # @param id [String] stable flag identifier, unique per account.
254
+ # @param default [String] value served when no environment override or
255
+ # rule applies.
256
+ # @param name [String, nil] human-readable display name; defaults to a
257
+ # title-cased form of +id+.
258
+ # @param description [String, nil] optional free-text description of the flag.
259
+ # @param values [Array<FlagValue>, nil] optional list of allowed values
260
+ # constraining what the flag may serve; when omitted the flag is
261
+ # unconstrained.
262
+ # @return [StringFlag] an unsaved flag; call +save+ to persist it.
263
+ def new_string_flag(id, default:, name: nil, description: nil, values: nil)
264
+ StringFlag.new(
265
+ self, id: id, name: name || Smplkit::Helpers.key_to_display_name(id),
266
+ type: "STRING", default: default, values: values, description: description
267
+ )
268
+ end
269
+
270
+ # Return a new unsaved numeric +NumberFlag+. Call +save+ to persist.
271
+ #
272
+ # @param id [String] stable flag identifier, unique per account.
273
+ # @param default [Numeric] value served when no environment override or
274
+ # rule applies.
275
+ # @param name [String, nil] human-readable display name; defaults to a
276
+ # title-cased form of +id+.
277
+ # @param description [String, nil] optional free-text description of the flag.
278
+ # @param values [Array<FlagValue>, nil] optional list of allowed values
279
+ # constraining what the flag may serve; when omitted the flag is
280
+ # unconstrained.
281
+ # @return [NumberFlag] an unsaved flag; call +save+ to persist it.
282
+ def new_number_flag(id, default:, name: nil, description: nil, values: nil)
283
+ NumberFlag.new(
284
+ self, id: id, name: name || Smplkit::Helpers.key_to_display_name(id),
285
+ type: "NUMERIC", default: default, values: values, description: description
286
+ )
287
+ end
288
+
289
+ # Return a new unsaved JSON +JsonFlag+. Call +save+ to persist.
290
+ #
291
+ # @param id [String] stable flag identifier, unique per account.
292
+ # @param default [Hash] value served when no environment override or
293
+ # rule applies.
294
+ # @param name [String, nil] human-readable display name; defaults to a
295
+ # title-cased form of +id+.
296
+ # @param description [String, nil] optional free-text description of the flag.
297
+ # @param values [Array<FlagValue>, nil] optional list of allowed values
298
+ # constraining what the flag may serve; when omitted the flag is
299
+ # unconstrained.
300
+ # @return [JsonFlag] an unsaved flag; call +save+ to persist it.
301
+ def new_json_flag(id, default:, name: nil, description: nil, values: nil)
302
+ JsonFlag.new(
303
+ self, id: id, name: name || Smplkit::Helpers.key_to_display_name(id),
304
+ type: "JSON", default: default, values: values, description: description
305
+ )
306
+ end
307
+
308
+ # Fetch the editable +Flag+ resource by id.
309
+ #
310
+ # @param id [String] identifier of the flag to fetch.
311
+ # @return [Flag] the flag, ready to mutate and +save+.
312
+ # @raise [Smplkit::NotFoundError] no flag with that id exists for the account.
313
+ def get(id)
314
+ response = ApiSupport::ErrorMapping.call { @api.get_flag(id) }
315
+ model_from_resource(ApiSupport::ResourceShim.from_model(response.data))
109
316
  end
110
317
 
111
- def string_flag(id, default:)
112
- register_handle(StringFlag, id, "STRING", default)
318
+ # List flags for the authenticated account.
319
+ #
320
+ # @param page_number [Integer, nil] 1-based page index to fetch; when
321
+ # omitted the server default applies.
322
+ # @param page_size [Integer, nil] number of flags per page; when omitted
323
+ # the server default applies.
324
+ # @return [Array<Flag>] the flags on the requested page.
325
+ def list(page_number: nil, page_size: nil)
326
+ opts = {}
327
+ opts[:page_number] = page_number unless page_number.nil?
328
+ opts[:page_size] = page_size unless page_size.nil?
329
+ response = ApiSupport::ErrorMapping.call { @api.list_flags(opts) }
330
+ (response.data || []).map { |r| model_from_resource(ApiSupport::ResourceShim.from_model(r)) }
331
+ end
332
+
333
+ # Delete a flag by id.
334
+ #
335
+ # @param id [String] identifier of the flag to delete.
336
+ # @return [void]
337
+ # @raise [Smplkit::NotFoundError] no flag with that id exists for the account.
338
+ def delete(id)
339
+ ApiSupport::ErrorMapping.call { @api.delete_flag(id) }
340
+ nil
113
341
  end
114
342
 
115
- def number_flag(id, default:)
116
- register_handle(NumberFlag, id, "NUMERIC", default)
343
+ def _create_flag(flag)
344
+ response = ApiSupport::ErrorMapping.call { @api.create_flag(flag_body(flag)) }
345
+ model_from_resource(ApiSupport::ResourceShim.from_model(response.data))
117
346
  end
118
347
 
119
- def json_flag(id, default:)
120
- register_handle(JsonFlag, id, "JSON", default)
348
+ def _update_flag(flag)
349
+ response = ApiSupport::ErrorMapping.call { @api.update_flag(flag.id, flag_body(flag)) }
350
+ model_from_resource(ApiSupport::ResourceShim.from_model(response.data))
121
351
  end
122
352
 
123
- # Eagerly initialize the flags subclient.
353
+ # ----------------------------------------------------------------
354
+ # Management surface: discovery buffer (owned directly)
355
+ # ----------------------------------------------------------------
356
+
357
+ # Buffer flag declarations for bulk-discovery upload; optionally flush now.
124
358
  #
125
- # Flushes any pending flag-declaration buffer, fetches all flag
126
- # definitions, opens the shared WebSocket and subscribes to
127
- # +flag_changed+ / +flag_deleted+ / +flags_changed+ events.
359
+ # @param items [FlagDeclaration, Array<FlagDeclaration>] a single
360
+ # declaration or an array of them to queue.
361
+ # @param flush [Boolean] when true, send the buffered declarations
362
+ # immediately via +flush+ before returning. When false (the default),
363
+ # they stay buffered and are sent on the next flush — automatic once the
364
+ # buffer reaches its batch size, or on the first live call.
365
+ # @return [void]
366
+ def register(items, flush: false)
367
+ batch = items.is_a?(Array) ? items : [items]
368
+ batch.each { |d| @buffer.add(d) }
369
+ if flush
370
+ self.flush
371
+ return
372
+ end
373
+ return unless @buffer.pending_count >= FLAG_BATCH_FLUSH_SIZE
374
+
375
+ Thread.new { threshold_flush }
376
+ end
377
+
378
+ # POST pending declarations to the flags bulk endpoint.
128
379
  #
129
- # Idempotent safe to call multiple times. Called automatically on
130
- # first +flag.get+ evaluation if not invoked manually.
380
+ # Items remain in the buffer until the request succeeds, so a flush
381
+ # against an unhealthy +flags+ service is automatically retried by the
382
+ # next +flush+ call (periodic background flush, install retry, or final
383
+ # flush on close).
131
384
  #
132
- # If the flags-service is unhealthy (e.g. a coordinated rebuild where
133
- # the app pod starts before the schema is loaded), the flush or refresh
134
- # will fail. Pending declarations stay queued, the client remains
135
- # disconnected, and the next call retries after an exponentially
136
- # backed-off delay (capped at +MAX_START_RETRY_DELAY+ seconds).
137
- # Evaluations during that window fall back to handle defaults.
138
- def start
139
- return if @connected
140
- return if Process.clock_gettime(Process::CLOCK_MONOTONIC) < @next_start_attempt_at
385
+ # @return [void]
386
+ def flush
387
+ batch = @buffer.peek
388
+ return if batch.empty?
141
389
 
142
- @environment = @parent._environment
390
+ body = build_flag_bulk_request(batch)
391
+ ApiSupport::ErrorMapping.call { @api.bulk_register_flags(body) }
392
+ @buffer.commit(batch.map { |b| b["id"] })
393
+ end
143
394
 
144
- begin
145
- @manage.flags.flush
146
- refresh
147
- rescue StandardError => e
148
- schedule_start_retry(e)
149
- return
150
- end
395
+ # Synchronous flush — alias of +flush+ for the periodic-flush path.
396
+ #
397
+ # @return [void]
398
+ def flush_sync
399
+ flush
400
+ end
151
401
 
152
- @connected = true
153
- @start_retry_delay = INITIAL_START_RETRY_DELAY
154
- @next_start_attempt_at = 0.0
402
+ # Number of pending flag declarations awaiting flush.
403
+ #
404
+ # @return [Integer] number of pending flag declarations awaiting flush.
405
+ def pending_count
406
+ @buffer.pending_count
407
+ end
155
408
 
156
- @ws_manager = @parent._ensure_ws
157
- return if @ws_subscribed
409
+ # ----------------------------------------------------------------
410
+ # Live surface: typed flag handles
411
+ # ----------------------------------------------------------------
158
412
 
159
- @ws_manager.on("flag_changed") { |data| handle_flag_changed(data) }
160
- @ws_manager.on("flag_deleted") { |data| handle_flag_deleted(data) }
161
- @ws_manager.on("flags_changed") { |data| handle_flags_changed(data) }
162
- @ws_subscribed = true
413
+ # Declare a boolean flag handle for live evaluation. Connects lazily on first use.
414
+ #
415
+ # @param id [String] identifier of the flag to evaluate.
416
+ # @param default [Boolean] value returned by +handle.get+ when the flag is
417
+ # unknown or no environment override or rule applies.
418
+ # @return [BooleanFlag] a handle whose +get+ evaluates against the live cache.
419
+ def boolean_flag(id, default:)
420
+ ensure_connected
421
+ handle = BooleanFlag.new(self, id: id, name: id, type: "BOOLEAN", default: default)
422
+ @handles[id] = handle
423
+ observe_declaration(id, "BOOLEAN", default)
424
+ handle
425
+ end
426
+
427
+ # Declare a string flag handle for live evaluation. Connects lazily on first use.
428
+ #
429
+ # @param id [String] identifier of the flag to evaluate.
430
+ # @param default [String] value returned by +handle.get+ when the flag is
431
+ # unknown or no environment override or rule applies.
432
+ # @return [StringFlag] a handle whose +get+ evaluates against the live cache.
433
+ def string_flag(id, default:)
434
+ ensure_connected
435
+ handle = StringFlag.new(self, id: id, name: id, type: "STRING", default: default)
436
+ @handles[id] = handle
437
+ observe_declaration(id, "STRING", default)
438
+ handle
163
439
  end
164
440
 
441
+ # Declare a numeric flag handle for live evaluation. Connects lazily on first use.
442
+ #
443
+ # @param id [String] identifier of the flag to evaluate.
444
+ # @param default [Numeric] value returned by +handle.get+ when the flag is
445
+ # unknown or no environment override or rule applies.
446
+ # @return [NumberFlag] a handle whose +get+ evaluates against the live cache.
447
+ def number_flag(id, default:)
448
+ ensure_connected
449
+ handle = NumberFlag.new(self, id: id, name: id, type: "NUMERIC", default: default)
450
+ @handles[id] = handle
451
+ observe_declaration(id, "NUMERIC", default)
452
+ handle
453
+ end
454
+
455
+ # Declare a JSON flag handle for live evaluation. Connects lazily on first use.
456
+ #
457
+ # @param id [String] identifier of the flag to evaluate.
458
+ # @param default [Hash] value returned by +handle.get+ when the flag is
459
+ # unknown or no environment override or rule applies.
460
+ # @return [JsonFlag] a handle whose +get+ evaluates against the live cache.
461
+ def json_flag(id, default:)
462
+ ensure_connected
463
+ handle = JsonFlag.new(self, id: id, name: id, type: "JSON", default: default)
464
+ @handles[id] = handle
465
+ observe_declaration(id, "JSON", default)
466
+ handle
467
+ end
468
+
469
+ # ----------------------------------------------------------------
470
+ # Live surface: refresh / stats / change listeners
471
+ # ----------------------------------------------------------------
472
+
473
+ # Re-fetch all flag definitions and clear cache.
474
+ #
475
+ # Connects lazily on first use — no explicit install step.
476
+ #
477
+ # @return [void]
165
478
  def refresh
166
- fetch_all_flags
167
- @cache.clear
168
- fire_change_listeners_all("manual")
479
+ ensure_connected
480
+ do_refresh("manual")
169
481
  end
170
482
 
483
+ # Return evaluation statistics. Connects lazily on first use.
484
+ #
485
+ # @return [FlagStats] the evaluation statistics.
171
486
  def stats
487
+ ensure_connected
172
488
  FlagStats.new(cache_hits: @cache.cache_hits, cache_misses: @cache.cache_misses)
173
489
  end
174
490
 
@@ -176,7 +492,15 @@ module Smplkit
176
492
  #
177
493
  # client.flags.on_change { |event| ... } # global
178
494
  # client.flags.on_change("checkout-v2") { |e| ... } # flag-scoped
495
+ #
496
+ # Connects lazily on first use — no explicit install step.
497
+ #
498
+ # @param flag_id [String, nil] optional flag id scoping the listener to
499
+ # that flag; when +nil+ a global listener is registered.
500
+ # @yield [FlagChangeEvent] the listener block, invoked on each change.
501
+ # @return [Proc] the registered listener block.
179
502
  def on_change(flag_id = nil, &block)
503
+ ensure_connected
180
504
  raise ArgumentError, "on_change requires a block" unless block
181
505
 
182
506
  if flag_id.nil?
@@ -187,25 +511,59 @@ module Smplkit
187
511
  block
188
512
  end
189
513
 
190
- def _close
191
- # No durable resources here — kept for symmetry with Python SDK.
514
+ # Release resources — only those this client owns.
515
+ #
516
+ # Tears down the owned WebSocket (standalone install). A wired client
517
+ # borrows the parent's transport, WebSocket, and contexts client and
518
+ # closes none of them.
519
+ #
520
+ # @return [void]
521
+ def close
522
+ if @owns_ws && @ws_manager
523
+ @ws_manager.stop
524
+ @ws_manager = nil
525
+ @owns_ws = false
526
+ end
527
+ # Owned flags/app transports (standalone construction) release their
528
+ # Faraday connections on GC; there is no explicit shutdown to call.
529
+ nil
192
530
  end
531
+ alias _close close
193
532
 
194
- def _evaluate_handle(flag_id, default, context)
195
- start unless @connected
533
+ # Construct, yield to the block, and close on exit.
534
+ #
535
+ # @yield [FlagsClient] the constructed client.
536
+ # @return [Object] the block's return value; closes the client on exit.
537
+ def self.open(**kwargs)
538
+ client = new(**kwargs)
539
+ begin
540
+ yield client
541
+ ensure
542
+ client.close
543
+ end
544
+ end
196
545
 
197
- eval_dict =
198
- if context
199
- @manage.contexts.register(context) if @manage.respond_to?(:contexts)
200
- contexts_to_eval_dict(context)
201
- else
202
- current = Smplkit.request_context
203
- current.empty? ? {} : contexts_to_eval_dict(current)
204
- end
546
+ # Core evaluation used by flag handles (the +.get+ path).
547
+ #
548
+ # Connects lazily on first use so +flag.get+ works without an explicit
549
+ # install step.
550
+ def _evaluate_handle(flag_id, default, context)
551
+ ensure_connected
552
+ if context
553
+ # Explicit context: register here. (Implicit set_context registers at
554
+ # the entry point, so the request-context branch below doesn't need
555
+ # to.)
556
+ @contexts&.register(context)
557
+ eval_dict = Flags.contexts_to_eval_dict(context)
558
+ else
559
+ contexts = Smplkit.request_context
560
+ eval_dict = contexts.empty? ? {} : Flags.contexts_to_eval_dict(contexts)
561
+ end
205
562
 
563
+ # Auto-inject service context if set and not already provided.
206
564
  eval_dict["service"] = { "key" => @service } if @service && !eval_dict.key?("service")
207
565
 
208
- ctx_hash = hash_context(eval_dict)
566
+ ctx_hash = Flags.hash_context(eval_dict)
209
567
  cache_key = "#{flag_id}:#{ctx_hash}"
210
568
 
211
569
  hit, cached_value = @cache.get(cache_key)
@@ -229,62 +587,131 @@ module Smplkit
229
587
  value
230
588
  end
231
589
 
590
+ # Internal: trigger lazy connect. Used by +Client#wait_until_ready+.
591
+ def _ensure_connected
592
+ ensure_connected
593
+ end
594
+
232
595
  private
233
596
 
234
- def register_handle(klass, id, type_name, default)
235
- handle = klass.new(self, id: id, name: id, type: type_name, default: default)
236
- @handles[id] = handle
237
- if @manage.respond_to?(:flags)
238
- @manage.flags.register(FlagDeclaration.new(
239
- id: id, type: type_name, default: default,
240
- service: @service, environment: @environment
241
- ))
597
+ # Queue a declared flag with the owned discovery buffer.
598
+ def observe_declaration(flag_id, flag_type, default)
599
+ register(FlagDeclaration.new(
600
+ id: flag_id, type: flag_type, default: default,
601
+ service: @service, environment: @environment
602
+ ))
603
+ end
604
+
605
+ def threshold_flush
606
+ flush
607
+ rescue StandardError => e
608
+ Smplkit.debug("registration", "flag registration flush failed: #{e.class}: #{e.message}")
609
+ end
610
+
611
+ def model_from_resource(resource)
612
+ d = Helpers.flag_dict_from_json(resource)
613
+ klass =
614
+ case d["type"]
615
+ when "BOOLEAN" then BooleanFlag
616
+ when "STRING" then StringFlag
617
+ when "NUMERIC" then NumberFlag
618
+ else JsonFlag
619
+ end
620
+ klass.new(
621
+ self,
622
+ id: d["id"], name: d["name"], type: d["type"], default: d["default"],
623
+ description: d["description"], values: d["values"], environments: d["environments"],
624
+ created_at: (resource["attributes"] || {})["created_at"],
625
+ updated_at: (resource["attributes"] || {})["updated_at"]
626
+ )
627
+ end
628
+
629
+ # ----------------------------------------------------------------
630
+ # Live surface: lazy connect + transport / WebSocket helpers
631
+ # ----------------------------------------------------------------
632
+
633
+ def ensure_ws
634
+ return @parent._ensure_ws unless @parent.nil?
635
+
636
+ if @ws_manager.nil?
637
+ @ws_manager = SharedWebSocket.new(
638
+ app_base_url: @app_base_url, api_key: @standalone_api_key, metrics: @metrics
639
+ )
640
+ @ws_manager.start
641
+ @owns_ws = true
242
642
  end
243
- handle
643
+ @ws_manager
644
+ end
645
+
646
+ # Open the live connection to the running Smpl Flags service.
647
+ #
648
+ # Flushes any buffered discovery declarations, fetches all flag
649
+ # definitions into the local cache, opens the shared WebSocket, and
650
+ # subscribes to +flag_changed+ / +flag_deleted+ / +flags_changed+ events.
651
+ #
652
+ # Idempotent and internal — every live method calls it on first use, so
653
+ # the live surface auto-connects with no explicit step.
654
+ def ensure_connected
655
+ @parent&._ensure_started
656
+ return if @connected
657
+
658
+ # Flush discovered flags BEFORE fetching definitions so the fetch
659
+ # reflects them. Items stay in the buffer until the POST succeeds.
660
+ begin
661
+ flush
662
+ rescue StandardError => e
663
+ Smplkit.debug("flags", "discovery flush before connect failed: #{e.class}: #{e.message}")
664
+ end
665
+
666
+ fetch_all_flags
667
+ @cache.clear
668
+ @connected = true
669
+
670
+ @ws_manager = ensure_ws
671
+ return if @ws_subscribed
672
+
673
+ @ws_manager.on("flag_changed") { |data| handle_flag_changed(data) }
674
+ @ws_manager.on("flag_deleted") { |data| handle_flag_deleted(data) }
675
+ @ws_manager.on("flags_changed") { |data| handle_flags_changed(data) }
676
+ @ws_subscribed = true
244
677
  end
245
678
 
246
- def schedule_start_retry(exc)
247
- delay = @start_retry_delay
248
- @next_start_attempt_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay
249
- @start_retry_delay = [delay * 2, MAX_START_RETRY_DELAY].min
250
- Smplkit.debug("registration",
251
- "flags client start failed (will retry in #{delay}s): #{exc.class}: #{exc.message}")
679
+ def do_refresh(_source)
680
+ fetch_all_flags
681
+ @cache.clear
682
+ fire_change_listeners_all("manual")
252
683
  end
253
684
 
685
+ # ----------------------------------------------------------------
686
+ # Internal: flag store
687
+ # ----------------------------------------------------------------
688
+
254
689
  def fetch_all_flags
255
- flags = @parent._flags_transport.list_flags
690
+ flags = fetch_flags_list
256
691
  @flag_store = flags.to_h { |f| [f["id"], f] }
257
- rescue Smplkit::Error
258
- raise
259
- rescue StandardError => e
260
- raise Smplkit::ConnectionError, "Failed to fetch flags: #{e.message}"
261
692
  end
262
693
 
263
- def contexts_to_eval_dict(contexts)
264
- contexts.to_h { |ctx| [ctx.type, ctx.to_eval_hash] }
694
+ def fetch_flags_list
695
+ rows = ApiSupport::PaginatedFetch.collect { |opts| @api.list_flags(opts) }
696
+ rows.map { |r| Helpers.flag_dict_from_json(ApiSupport::ResourceShim.from_model(r)) }
265
697
  end
266
698
 
267
- def hash_context(eval_dict)
268
- Digest::MD5.hexdigest(JSON.generate(deep_sort(eval_dict)))
699
+ # Fetch a single flag by key and return a store-format dict.
700
+ def fetch_flag_single_data(key)
701
+ response = ApiSupport::ErrorMapping.call { @api.get_flag(key) }
702
+ Helpers.flag_dict_from_json(ApiSupport::ResourceShim.from_model(response.data))
269
703
  end
270
704
 
271
- def deep_sort(value)
272
- case value
273
- when Hash
274
- value.keys.sort_by(&:to_s).to_h { |k| [k, deep_sort(value[k])] }
275
- when Array
276
- value.map { |v| deep_sort(v) }
277
- else
278
- value
279
- end
280
- end
705
+ # ----------------------------------------------------------------
706
+ # Internal: event handlers (called by SharedWebSocket)
707
+ # ----------------------------------------------------------------
281
708
 
282
709
  def handle_flag_changed(data)
283
710
  key = data["id"]
284
711
  return unless key
285
712
 
286
713
  pre = @flag_store[key]&.dup || {}
287
- new_data = @parent._flags_transport.fetch_flag(key)
714
+ new_data = fetch_flag_single_data(key)
288
715
  @flag_store[key] = new_data
289
716
  @cache.clear
290
717
  fire_change_listeners(key, "websocket") if pre != new_data
@@ -314,6 +741,7 @@ module Smplkit
314
741
  changed = all_keys.reject { |k| pre_store[k] == post_store[k] }
315
742
  return if changed.empty?
316
743
 
744
+ # Global listener fires once.
317
745
  first_event = FlagChangeEvent.new(id: changed.first, source: "websocket")
318
746
  @global_listeners.each do |cb|
319
747
  cb.call(first_event)
@@ -321,6 +749,7 @@ module Smplkit
321
749
  Smplkit.debug("flags", "global listener raised: #{e.class}: #{e.message}")
322
750
  end
323
751
 
752
+ # Per-key listeners fire for each changed key.
324
753
  changed.each do |k|
325
754
  deleted = pre_store.key?(k) && !post_store.key?(k)
326
755
  event = FlagChangeEvent.new(id: k, source: "websocket", deleted: deleted)
@@ -349,9 +778,9 @@ module Smplkit
349
778
 
350
779
  # Evaluate a flag definition against the given context.
351
780
  #
352
- # Follows ADR-022 §2.6 semantics:
353
- # 1. Look up the environment. If missing, return flag-level default.
354
- # 2. If disabled, return env default or flag default.
781
+ # Evaluation steps:
782
+ # 1. Look up the environment. If missing, return the flag-level default.
783
+ # 2. If disabled, return the env default or flag default.
355
784
  # 3. Iterate rules; first match wins.
356
785
  # 4. No match -> env default or flag default.
357
786
  def evaluate_flag(flag_def, environment, eval_dict)
@@ -378,15 +807,70 @@ module Smplkit
378
807
 
379
808
  fallback
380
809
  end
810
+
811
+ def flag_body(flag)
812
+ SmplkitGeneratedClient::Flags::FlagResponse.new(
813
+ data: SmplkitGeneratedClient::Flags::FlagResource.new(
814
+ type: "flag",
815
+ id: flag.id,
816
+ attributes: SmplkitGeneratedClient::Flags::Flag.new(
817
+ name: flag.name,
818
+ type: flag.type,
819
+ default: flag.default,
820
+ description: flag.description,
821
+ values: flag_values_to_wire(flag.values),
822
+ environments: flag_envs_to_wire(flag.environments)
823
+ )
824
+ )
825
+ )
826
+ end
827
+
828
+ def flag_values_to_wire(values)
829
+ # The server requires +values+ to be null (unconstrained) or a
830
+ # non-empty array (constrained) — an empty array is rejected, so an
831
+ # empty/cleared values list is sent as null.
832
+ return nil if values.nil? || values.empty?
833
+
834
+ values.map do |v|
835
+ SmplkitGeneratedClient::Flags::FlagValue.new(name: v.name, value: v.value)
836
+ end
837
+ end
838
+
839
+ def flag_envs_to_wire(environments)
840
+ return nil if environments.empty?
841
+
842
+ environments.each_with_object({}) do |(env_key, env_obj), out|
843
+ rules = env_obj.rules.map do |r|
844
+ SmplkitGeneratedClient::Flags::FlagRule.new(
845
+ logic: r.logic, value: r.value, description: r.description
846
+ )
847
+ end
848
+ out[env_key] = SmplkitGeneratedClient::Flags::FlagEnvironment.new(
849
+ enabled: env_obj.enabled, default: env_obj.default, rules: rules
850
+ )
851
+ end
852
+ end
853
+
854
+ def build_flag_bulk_request(batch)
855
+ flag_items = batch.map do |entry|
856
+ SmplkitGeneratedClient::Flags::FlagBulkItem.new(
857
+ id: entry["id"], type: entry["type"], default: entry["default"],
858
+ service: entry["service"], environment: entry["environment"]
859
+ )
860
+ end
861
+ SmplkitGeneratedClient::Flags::FlagBulkRequest.new(flags: flag_items)
862
+ end
381
863
  end
382
864
 
383
865
  # Vendored minimal JSON Logic evaluator covering the operators the smplkit
384
866
  # platform ships in flag rules.
385
867
  #
386
- # Stays in-tree so the Ruby SDK doesn't depend on the +json_logic+ gem
387
- # being correct — the Java SDK followed the same pattern. Operators
388
- # supported: +==+, +!=+, +<+, +<=+, +>+, +>=+, +in+, +var+, +and+, +or+,
389
- # +!+, +if+, +missing+, +none+.
868
+ # Stays in-tree so the Ruby SDK doesn't depend on the +json_logic+ gem being
869
+ # correct — the Java SDK followed the same pattern. Operators supported:
870
+ # +==+, +!=+, +<+, +<=+, +>+, +>=+, +in+, +var+, +and+, +or+, +!+, +if+,
871
+ # +missing+, +none+.
872
+ #
873
+ # @api private
390
874
  module JsonLogicEvaluator
391
875
  module_function
392
876
 
@@ -505,4 +989,6 @@ module Smplkit
505
989
  end
506
990
  end
507
991
  end
992
+
993
+ FlagsClient = Flags::FlagsClient
508
994
  end