smplkit 3.0.95 → 3.0.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,35 @@
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
+ # * *Management 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.
@@ -41,6 +67,7 @@ module Smplkit
41
67
  @cache_misses = 0
42
68
  end
43
69
 
70
+ # Return [hit, value]. Moves the key to end on hit.
44
71
  def get(cache_key)
45
72
  @lock.synchronize do
46
73
  if @cache.key?(cache_key)
@@ -71,104 +98,277 @@ module Smplkit
71
98
  # Evaluation statistics for the flags runtime.
72
99
  FlagStats = Struct.new(:cache_hits, :cache_misses, keyword_init: true)
73
100
 
74
- # Synchronous flags runtime namespace.
101
+ # Convert a list of Context objects to the nested evaluation dict.
102
+ def self.contexts_to_eval_dict(contexts)
103
+ contexts.to_h { |ctx| [ctx.type, ctx.to_eval_hash] }
104
+ end
105
+
106
+ # Compute a stable hash for a context evaluation dict.
107
+ def self.hash_context(eval_dict)
108
+ Digest::MD5.hexdigest(JSON.generate(deep_sort(eval_dict)))
109
+ end
110
+
111
+ def self.deep_sort(value)
112
+ case value
113
+ when Hash
114
+ value.keys.sort_by(&:to_s).to_h { |k| [k, deep_sort(value[k])] }
115
+ when Array
116
+ value.map { |v| deep_sort(v) }
117
+ else
118
+ value
119
+ end
120
+ end
121
+
122
+ # Build standalone flags + app transports and resolve the app base URL.
75
123
  #
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
124
+ # +base_url+/+api_key+ are used directly when supplied (the path a top-level
125
+ # client takes after it has already resolved them); otherwise the management
126
+ # config resolver fills in whatever is missing (+~/.smplkit+ / env vars /
127
+ # defaults). The app transport backs the standalone contexts client
128
+ # (evaluation-context registration); the app base URL is returned so a
129
+ # standalone client can open its own WebSocket against the event gateway.
130
+ def self.flags_transport(api_key:, base_url:, profile:, base_domain:, scheme:, debug:, extra_headers:)
131
+ cfg = ConfigResolution.resolve_management_config(
132
+ profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme, debug: debug
133
+ )
134
+ resolved_key = api_key.nil? ? cfg.api_key : api_key
135
+ merged = {}
136
+ merged.merge!(cfg.extra_headers || {})
137
+ merged.merge!(extra_headers || {})
138
+ tcfg = ConfigResolution::ResolvedManagementConfig.new(
139
+ api_key: resolved_key, base_domain: cfg.base_domain, scheme: cfg.scheme,
140
+ debug: cfg.debug, extra_headers: merged
141
+ )
142
+ app_url = ConfigResolution.service_url(cfg.scheme, "app", cfg.base_domain)
143
+ flags_http = Transport.build_api_client(SmplkitGeneratedClient::Flags, "flags", tcfg, base_url: base_url)
144
+ app_http = Transport.build_api_client(SmplkitGeneratedClient::App, "app", tcfg)
145
+ [flags_http, app_http, app_url, resolved_key]
146
+ end
84
147
 
85
- def initialize(parent, manage:, metrics:, flags_base_url:, app_base_url:)
148
+ # The Smpl Flags client (sync).
149
+ #
150
+ # One client exposes the full surface, reachable as +client.flags+
151
+ # (+Smplkit::Client+) or constructed directly:
152
+ #
153
+ # flags = Smplkit::FlagsClient.new(environment: "production")
154
+ # new_flag = flags.new_boolean_flag("beta", default: false)
155
+ # new_flag.save
156
+ # beta = flags.boolean_flag("beta", default: false)
157
+ # beta.get # => ...
158
+ #
159
+ # The management surface (+new_*+ / +get+ / +list+ / +delete+ and discovery)
160
+ # is pure CRUD. The live surface (+boolean_flag+ / +string_flag+ /
161
+ # +number_flag+ / +json_flag+ / +refresh+ / +stats+ / +on_change+) connects
162
+ # lazily on first use — the first call flushes discovery, fetches all flag
163
+ # definitions into the local cache, and opens the live-updates WebSocket. No
164
+ # explicit install step is required.
165
+ class FlagsClient
166
+ def initialize(api_key = nil, environment: nil, base_url: nil, profile: nil,
167
+ base_domain: nil, scheme: nil, debug: nil, extra_headers: nil,
168
+ parent: nil, transport: nil, contexts: nil, metrics: nil)
86
169
  @parent = parent
87
- @manage = manage
88
170
  @metrics = metrics
89
- @service = parent._service
90
- @environment = parent._environment
91
- @flags_base_url = flags_base_url
92
- @app_base_url = app_base_url
171
+ @environment = parent.nil? ? environment : parent._environment
172
+ @service = parent&._service
173
+ @standalone_api_key = nil
174
+ if transport.nil?
175
+ @flags_http, app_http, @app_base_url, @standalone_api_key = Flags.flags_transport(
176
+ api_key: api_key, base_url: base_url, profile: profile,
177
+ base_domain: base_domain, scheme: scheme, debug: debug, extra_headers: extra_headers
178
+ )
179
+ # Standalone: build our own contexts client (and own its app transport).
180
+ @contexts = Platform::ContextsClient.new(app_http, ContextRegistrationBuffer.new)
181
+ else
182
+ @flags_http = transport
183
+ @app_base_url = nil
184
+ # Wired: borrow client.platform.contexts as the evaluation-context
185
+ # registration seam.
186
+ @contexts = contexts
187
+ end
188
+ @api = SmplkitGeneratedClient::Flags::FlagsApi.new(@flags_http)
93
189
 
190
+ # Discovery buffer is owned by this client (no management delegation).
191
+ @buffer = FlagRegistrationBuffer.new
192
+
193
+ # Live-surface state.
94
194
  @flag_store = {}
95
195
  @connected = false
96
196
  @ws_subscribed = false
97
- @next_start_attempt_at = 0.0
98
- @start_retry_delay = INITIAL_START_RETRY_DELAY
99
197
  @cache = ResolutionCache.new
100
198
  @handles = {}
101
199
  @global_listeners = []
102
200
  @key_listeners = Hash.new { |h, k| h[k] = [] }
103
201
  @ws_manager = nil
202
+ @owns_ws = false
104
203
  @lock = Mutex.new
105
204
  end
106
205
 
107
- def boolean_flag(id, default:)
108
- register_handle(BooleanFlag, id, "BOOLEAN", default)
206
+ # ----------------------------------------------------------------
207
+ # Management surface: CRUD (no live connection)
208
+ # ----------------------------------------------------------------
209
+
210
+ # Return a new unsaved boolean +BooleanFlag+. Call +save+ to persist.
211
+ def new_boolean_flag(id, default:, name: nil, description: nil)
212
+ BooleanFlag.new(
213
+ self, id: id, name: name || Smplkit::Helpers.key_to_display_name(id),
214
+ type: "BOOLEAN", default: default,
215
+ values: [FlagValue.new(name: "True", value: true), FlagValue.new(name: "False", value: false)],
216
+ description: description
217
+ )
109
218
  end
110
219
 
111
- def string_flag(id, default:)
112
- register_handle(StringFlag, id, "STRING", default)
220
+ # Return a new unsaved string +StringFlag+. Call +save+ to persist.
221
+ def new_string_flag(id, default:, name: nil, description: nil, values: nil)
222
+ StringFlag.new(
223
+ self, id: id, name: name || Smplkit::Helpers.key_to_display_name(id),
224
+ type: "STRING", default: default, values: values, description: description
225
+ )
113
226
  end
114
227
 
115
- def number_flag(id, default:)
116
- register_handle(NumberFlag, id, "NUMERIC", default)
228
+ # Return a new unsaved numeric +NumberFlag+. Call +save+ to persist.
229
+ def new_number_flag(id, default:, name: nil, description: nil, values: nil)
230
+ NumberFlag.new(
231
+ self, id: id, name: name || Smplkit::Helpers.key_to_display_name(id),
232
+ type: "NUMERIC", default: default, values: values, description: description
233
+ )
117
234
  end
118
235
 
119
- def json_flag(id, default:)
120
- register_handle(JsonFlag, id, "JSON", default)
236
+ # Return a new unsaved JSON +JsonFlag+. Call +save+ to persist.
237
+ def new_json_flag(id, default:, name: nil, description: nil, values: nil)
238
+ JsonFlag.new(
239
+ self, id: id, name: name || Smplkit::Helpers.key_to_display_name(id),
240
+ type: "JSON", default: default, values: values, description: description
241
+ )
121
242
  end
122
243
 
123
- # Eagerly initialize the flags subclient.
124
- #
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.
128
- #
129
- # Idempotent — safe to call multiple times. Called automatically on
130
- # first +flag.get+ evaluation if not invoked manually.
131
- #
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
244
+ # Fetch the editable +Flag+ resource by id.
245
+ def get(id)
246
+ response = ApiSupport::ErrorMapping.call { @api.get_flag(id) }
247
+ model_from_resource(ApiSupport::ResourceShim.from_model(response.data))
248
+ end
141
249
 
142
- @environment = @parent._environment
250
+ # List flags for the authenticated account.
251
+ def list(page_number: nil, page_size: nil)
252
+ opts = {}
253
+ opts[:page_number] = page_number unless page_number.nil?
254
+ opts[:page_size] = page_size unless page_size.nil?
255
+ response = ApiSupport::ErrorMapping.call { @api.list_flags(opts) }
256
+ (response.data || []).map { |r| model_from_resource(ApiSupport::ResourceShim.from_model(r)) }
257
+ end
143
258
 
144
- begin
145
- @manage.flags.flush
146
- refresh
147
- rescue StandardError => e
148
- schedule_start_retry(e)
259
+ # Delete a flag by id.
260
+ def delete(id)
261
+ ApiSupport::ErrorMapping.call { @api.delete_flag(id) }
262
+ nil
263
+ end
264
+
265
+ def _create_flag(flag)
266
+ response = ApiSupport::ErrorMapping.call { @api.create_flag(flag_body(flag)) }
267
+ model_from_resource(ApiSupport::ResourceShim.from_model(response.data))
268
+ end
269
+
270
+ def _update_flag(flag)
271
+ response = ApiSupport::ErrorMapping.call { @api.update_flag(flag.id, flag_body(flag)) }
272
+ model_from_resource(ApiSupport::ResourceShim.from_model(response.data))
273
+ end
274
+
275
+ # ----------------------------------------------------------------
276
+ # Management surface: discovery buffer (owned directly)
277
+ # ----------------------------------------------------------------
278
+
279
+ # Buffer flag declarations for bulk-discovery upload; optionally flush now.
280
+ def register(items, flush: false)
281
+ batch = items.is_a?(Array) ? items : [items]
282
+ batch.each { |d| @buffer.add(d) }
283
+ if flush
284
+ self.flush
149
285
  return
150
286
  end
287
+ return unless @buffer.pending_count >= FLAG_BATCH_FLUSH_SIZE
151
288
 
152
- @connected = true
153
- @start_retry_delay = INITIAL_START_RETRY_DELAY
154
- @next_start_attempt_at = 0.0
289
+ Thread.new { threshold_flush }
290
+ end
155
291
 
156
- @ws_manager = @parent._ensure_ws
157
- return if @ws_subscribed
292
+ # POST pending declarations to the flags bulk endpoint.
293
+ #
294
+ # Items remain in the buffer until the request succeeds, so a flush
295
+ # against an unhealthy +flags+ service is automatically retried by the
296
+ # next +flush+ call (periodic background flush, install retry, or final
297
+ # flush on close).
298
+ def flush
299
+ batch = @buffer.peek
300
+ return if batch.empty?
158
301
 
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
302
+ body = build_flag_bulk_request(batch)
303
+ ApiSupport::ErrorMapping.call { @api.bulk_register_flags(body) }
304
+ @buffer.commit(batch.map { |b| b["id"] })
305
+ end
306
+
307
+ # Synchronous flush — alias of +flush+ for the periodic-flush path.
308
+ def flush_sync
309
+ flush
310
+ end
311
+
312
+ # Number of pending flag declarations awaiting flush.
313
+ def pending_count
314
+ @buffer.pending_count
315
+ end
316
+
317
+ # ----------------------------------------------------------------
318
+ # Live surface: typed flag handles
319
+ # ----------------------------------------------------------------
320
+
321
+ # Declare a boolean flag handle for live evaluation. Connects lazily on first use.
322
+ def boolean_flag(id, default:)
323
+ ensure_connected
324
+ handle = BooleanFlag.new(self, id: id, name: id, type: "BOOLEAN", default: default)
325
+ @handles[id] = handle
326
+ observe_declaration(id, "BOOLEAN", default)
327
+ handle
328
+ end
329
+
330
+ # Declare a string flag handle for live evaluation. Connects lazily on first use.
331
+ def string_flag(id, default:)
332
+ ensure_connected
333
+ handle = StringFlag.new(self, id: id, name: id, type: "STRING", default: default)
334
+ @handles[id] = handle
335
+ observe_declaration(id, "STRING", default)
336
+ handle
337
+ end
338
+
339
+ # Declare a numeric flag handle for live evaluation. Connects lazily on first use.
340
+ def number_flag(id, default:)
341
+ ensure_connected
342
+ handle = NumberFlag.new(self, id: id, name: id, type: "NUMERIC", default: default)
343
+ @handles[id] = handle
344
+ observe_declaration(id, "NUMERIC", default)
345
+ handle
346
+ end
347
+
348
+ # Declare a JSON flag handle for live evaluation. Connects lazily on first use.
349
+ def json_flag(id, default:)
350
+ ensure_connected
351
+ handle = JsonFlag.new(self, id: id, name: id, type: "JSON", default: default)
352
+ @handles[id] = handle
353
+ observe_declaration(id, "JSON", default)
354
+ handle
163
355
  end
164
356
 
357
+ # ----------------------------------------------------------------
358
+ # Live surface: refresh / stats / change listeners
359
+ # ----------------------------------------------------------------
360
+
361
+ # Re-fetch all flag definitions and clear cache.
362
+ #
363
+ # Connects lazily on first use — no explicit install step.
165
364
  def refresh
166
- fetch_all_flags
167
- @cache.clear
168
- fire_change_listeners_all("manual")
365
+ ensure_connected
366
+ do_refresh("manual")
169
367
  end
170
368
 
369
+ # Return evaluation statistics. Connects lazily on first use.
171
370
  def stats
371
+ ensure_connected
172
372
  FlagStats.new(cache_hits: @cache.cache_hits, cache_misses: @cache.cache_misses)
173
373
  end
174
374
 
@@ -176,7 +376,10 @@ module Smplkit
176
376
  #
177
377
  # client.flags.on_change { |event| ... } # global
178
378
  # client.flags.on_change("checkout-v2") { |e| ... } # flag-scoped
379
+ #
380
+ # Connects lazily on first use — no explicit install step.
179
381
  def on_change(flag_id = nil, &block)
382
+ ensure_connected
180
383
  raise ArgumentError, "on_change requires a block" unless block
181
384
 
182
385
  if flag_id.nil?
@@ -187,25 +390,54 @@ module Smplkit
187
390
  block
188
391
  end
189
392
 
190
- def _close
191
- # No durable resources here — kept for symmetry with Python SDK.
393
+ # Release resources — only those this client owns.
394
+ #
395
+ # Tears down the owned WebSocket (standalone install). A wired client
396
+ # borrows the parent's transport, WebSocket, and contexts client and
397
+ # closes none of them.
398
+ def close
399
+ if @owns_ws && @ws_manager
400
+ @ws_manager.stop
401
+ @ws_manager = nil
402
+ @owns_ws = false
403
+ end
404
+ # Owned flags/app transports (standalone construction) release their
405
+ # Faraday connections on GC; there is no explicit shutdown to call.
406
+ nil
407
+ end
408
+ alias _close close
409
+
410
+ # Construct, yield to the block, and close on exit.
411
+ def self.open(**kwargs)
412
+ client = new(**kwargs)
413
+ begin
414
+ yield client
415
+ ensure
416
+ client.close
417
+ end
192
418
  end
193
419
 
420
+ # Core evaluation used by flag handles (the +.get+ path).
421
+ #
422
+ # Connects lazily on first use so +flag.get+ works without an explicit
423
+ # install step.
194
424
  def _evaluate_handle(flag_id, default, context)
195
- start unless @connected
196
-
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
425
+ ensure_connected
426
+ if context
427
+ # Explicit context: register here. (Implicit set_context registers at
428
+ # the entry point, so the request-context branch below doesn't need
429
+ # to.)
430
+ @contexts&.register(context)
431
+ eval_dict = Flags.contexts_to_eval_dict(context)
432
+ else
433
+ contexts = Smplkit.request_context
434
+ eval_dict = contexts.empty? ? {} : Flags.contexts_to_eval_dict(contexts)
435
+ end
205
436
 
437
+ # Auto-inject service context if set and not already provided.
206
438
  eval_dict["service"] = { "key" => @service } if @service && !eval_dict.key?("service")
207
439
 
208
- ctx_hash = hash_context(eval_dict)
440
+ ctx_hash = Flags.hash_context(eval_dict)
209
441
  cache_key = "#{flag_id}:#{ctx_hash}"
210
442
 
211
443
  hit, cached_value = @cache.get(cache_key)
@@ -229,62 +461,131 @@ module Smplkit
229
461
  value
230
462
  end
231
463
 
464
+ # Internal: trigger lazy connect. Used by +Client#wait_until_ready+.
465
+ def _ensure_connected
466
+ ensure_connected
467
+ end
468
+
232
469
  private
233
470
 
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
- ))
471
+ # Queue a declared flag with the owned discovery buffer.
472
+ def observe_declaration(flag_id, flag_type, default)
473
+ register(FlagDeclaration.new(
474
+ id: flag_id, type: flag_type, default: default,
475
+ service: @service, environment: @environment
476
+ ))
477
+ end
478
+
479
+ def threshold_flush
480
+ flush
481
+ rescue StandardError => e
482
+ Smplkit.debug("registration", "flag registration flush failed: #{e.class}: #{e.message}")
483
+ end
484
+
485
+ def model_from_resource(resource)
486
+ d = Helpers.flag_dict_from_json(resource)
487
+ klass =
488
+ case d["type"]
489
+ when "BOOLEAN" then BooleanFlag
490
+ when "STRING" then StringFlag
491
+ when "NUMERIC" then NumberFlag
492
+ else JsonFlag
493
+ end
494
+ klass.new(
495
+ self,
496
+ id: d["id"], name: d["name"], type: d["type"], default: d["default"],
497
+ description: d["description"], values: d["values"], environments: d["environments"],
498
+ created_at: (resource["attributes"] || {})["created_at"],
499
+ updated_at: (resource["attributes"] || {})["updated_at"]
500
+ )
501
+ end
502
+
503
+ # ----------------------------------------------------------------
504
+ # Live surface: lazy connect + transport / WebSocket helpers
505
+ # ----------------------------------------------------------------
506
+
507
+ def ensure_ws
508
+ return @parent._ensure_ws unless @parent.nil?
509
+
510
+ if @ws_manager.nil?
511
+ @ws_manager = SharedWebSocket.new(
512
+ app_base_url: @app_base_url, api_key: @standalone_api_key, metrics: @metrics
513
+ )
514
+ @ws_manager.start
515
+ @owns_ws = true
242
516
  end
243
- handle
517
+ @ws_manager
244
518
  end
245
519
 
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}")
520
+ # Open the live connection to the running Smpl Flags service.
521
+ #
522
+ # Flushes any buffered discovery declarations, fetches all flag
523
+ # definitions into the local cache, opens the shared WebSocket, and
524
+ # subscribes to +flag_changed+ / +flag_deleted+ / +flags_changed+ events.
525
+ #
526
+ # Idempotent and internal — every live method calls it on first use, so
527
+ # the live surface auto-connects with no explicit step.
528
+ def ensure_connected
529
+ @parent&._ensure_started
530
+ return if @connected
531
+
532
+ # Flush discovered flags BEFORE fetching definitions so the fetch
533
+ # reflects them. Items stay in the buffer until the POST succeeds.
534
+ begin
535
+ flush
536
+ rescue StandardError => e
537
+ Smplkit.debug("flags", "discovery flush before connect failed: #{e.class}: #{e.message}")
538
+ end
539
+
540
+ fetch_all_flags
541
+ @cache.clear
542
+ @connected = true
543
+
544
+ @ws_manager = ensure_ws
545
+ return if @ws_subscribed
546
+
547
+ @ws_manager.on("flag_changed") { |data| handle_flag_changed(data) }
548
+ @ws_manager.on("flag_deleted") { |data| handle_flag_deleted(data) }
549
+ @ws_manager.on("flags_changed") { |data| handle_flags_changed(data) }
550
+ @ws_subscribed = true
551
+ end
552
+
553
+ def do_refresh(_source)
554
+ fetch_all_flags
555
+ @cache.clear
556
+ fire_change_listeners_all("manual")
252
557
  end
253
558
 
559
+ # ----------------------------------------------------------------
560
+ # Internal: flag store
561
+ # ----------------------------------------------------------------
562
+
254
563
  def fetch_all_flags
255
- flags = @parent._flags_transport.list_flags
564
+ flags = fetch_flags_list
256
565
  @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
566
  end
262
567
 
263
- def contexts_to_eval_dict(contexts)
264
- contexts.to_h { |ctx| [ctx.type, ctx.to_eval_hash] }
568
+ def fetch_flags_list
569
+ rows = ApiSupport::PaginatedFetch.collect { |opts| @api.list_flags(opts) }
570
+ rows.map { |r| Helpers.flag_dict_from_json(ApiSupport::ResourceShim.from_model(r)) }
265
571
  end
266
572
 
267
- def hash_context(eval_dict)
268
- Digest::MD5.hexdigest(JSON.generate(deep_sort(eval_dict)))
573
+ # Fetch a single flag by key and return a store-format dict.
574
+ def fetch_flag_single_data(key)
575
+ response = ApiSupport::ErrorMapping.call { @api.get_flag(key) }
576
+ Helpers.flag_dict_from_json(ApiSupport::ResourceShim.from_model(response.data))
269
577
  end
270
578
 
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
579
+ # ----------------------------------------------------------------
580
+ # Internal: event handlers (called by SharedWebSocket)
581
+ # ----------------------------------------------------------------
281
582
 
282
583
  def handle_flag_changed(data)
283
584
  key = data["id"]
284
585
  return unless key
285
586
 
286
587
  pre = @flag_store[key]&.dup || {}
287
- new_data = @parent._flags_transport.fetch_flag(key)
588
+ new_data = fetch_flag_single_data(key)
288
589
  @flag_store[key] = new_data
289
590
  @cache.clear
290
591
  fire_change_listeners(key, "websocket") if pre != new_data
@@ -314,6 +615,7 @@ module Smplkit
314
615
  changed = all_keys.reject { |k| pre_store[k] == post_store[k] }
315
616
  return if changed.empty?
316
617
 
618
+ # Global listener fires once.
317
619
  first_event = FlagChangeEvent.new(id: changed.first, source: "websocket")
318
620
  @global_listeners.each do |cb|
319
621
  cb.call(first_event)
@@ -321,6 +623,7 @@ module Smplkit
321
623
  Smplkit.debug("flags", "global listener raised: #{e.class}: #{e.message}")
322
624
  end
323
625
 
626
+ # Per-key listeners fire for each changed key.
324
627
  changed.each do |k|
325
628
  deleted = pre_store.key?(k) && !post_store.key?(k)
326
629
  event = FlagChangeEvent.new(id: k, source: "websocket", deleted: deleted)
@@ -378,15 +681,68 @@ module Smplkit
378
681
 
379
682
  fallback
380
683
  end
684
+
685
+ def flag_body(flag)
686
+ SmplkitGeneratedClient::Flags::FlagResponse.new(
687
+ data: SmplkitGeneratedClient::Flags::FlagResource.new(
688
+ type: "flag",
689
+ id: flag.id,
690
+ attributes: SmplkitGeneratedClient::Flags::Flag.new(
691
+ name: flag.name,
692
+ type: flag.type,
693
+ default: flag.default,
694
+ description: flag.description,
695
+ values: flag_values_to_wire(flag.values),
696
+ environments: flag_envs_to_wire(flag.environments)
697
+ )
698
+ )
699
+ )
700
+ end
701
+
702
+ def flag_values_to_wire(values)
703
+ # The server requires +values+ to be null (unconstrained) or a
704
+ # non-empty array (constrained) — an empty array is rejected, so an
705
+ # empty/cleared values list is sent as null.
706
+ return nil if values.nil? || values.empty?
707
+
708
+ values.map do |v|
709
+ SmplkitGeneratedClient::Flags::FlagValue.new(name: v.name, value: v.value)
710
+ end
711
+ end
712
+
713
+ def flag_envs_to_wire(environments)
714
+ return nil if environments.empty?
715
+
716
+ environments.each_with_object({}) do |(env_key, env_obj), out|
717
+ rules = env_obj.rules.map do |r|
718
+ SmplkitGeneratedClient::Flags::FlagRule.new(
719
+ logic: r.logic, value: r.value, description: r.description
720
+ )
721
+ end
722
+ out[env_key] = SmplkitGeneratedClient::Flags::FlagEnvironment.new(
723
+ enabled: env_obj.enabled, default: env_obj.default, rules: rules
724
+ )
725
+ end
726
+ end
727
+
728
+ def build_flag_bulk_request(batch)
729
+ flag_items = batch.map do |entry|
730
+ SmplkitGeneratedClient::Flags::FlagBulkItem.new(
731
+ id: entry["id"], type: entry["type"], default: entry["default"],
732
+ service: entry["service"], environment: entry["environment"]
733
+ )
734
+ end
735
+ SmplkitGeneratedClient::Flags::FlagBulkRequest.new(flags: flag_items)
736
+ end
381
737
  end
382
738
 
383
739
  # Vendored minimal JSON Logic evaluator covering the operators the smplkit
384
740
  # platform ships in flag rules.
385
741
  #
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+.
742
+ # Stays in-tree so the Ruby SDK doesn't depend on the +json_logic+ gem being
743
+ # correct — the Java SDK followed the same pattern. Operators supported:
744
+ # +==+, +!=+, +<+, +<=+, +>+, +>=+, +in+, +var+, +and+, +or+, +!+, +if+,
745
+ # +missing+, +none+.
390
746
  module JsonLogicEvaluator
391
747
  module_function
392
748
 
@@ -505,4 +861,6 @@ module Smplkit
505
861
  end
506
862
  end
507
863
  end
864
+
865
+ FlagsClient = Flags::FlagsClient
508
866
  end