confeature 0.0.0 → 0.1.0

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.
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ module Confeature
6
+ # How flag overrides interact with the remote config.
7
+ #
8
+ # Only `LOCAL_ONLY` is wired in v1 — `LOCAL_OVER_REMOTE` and
9
+ # `REMOTE_OVER_LOCAL` are accepted at the type level and raise
10
+ # `NotImplementedError` at evaluation time (spec 031 §R8 deferral,
11
+ # mirrors Python + Go behaviour).
12
+ module OverrideBehaviour
13
+ LOCAL_ONLY = :local_only
14
+ LOCAL_OVER_REMOTE = :local_over_remote
15
+ REMOTE_OVER_LOCAL = :remote_over_local
16
+
17
+ ALL = [LOCAL_ONLY, LOCAL_OVER_REMOTE, REMOTE_OVER_LOCAL].freeze
18
+ end
19
+
20
+ # One slot of a `config_v6.json` setting value record.
21
+ #
22
+ # Exactly one of `b`, `s`, `i`, `d` is non-nil for a valid setting.
23
+ # The override-eval helper picks the first non-nil field in the
24
+ # B → S → I → D order, matching the TS / Python / Go canonical
25
+ # behaviour.
26
+ SettingValue = Struct.new(:b, :s, :i, :d, keyword_init: true) do
27
+ def empty?
28
+ b.nil? && s.nil? && i.nil? && d.nil?
29
+ end
30
+
31
+ def picked
32
+ return b unless b.nil?
33
+ return s unless s.nil?
34
+ return i unless i.nil?
35
+
36
+ d
37
+ end
38
+ end
39
+
40
+ # Abstract base for override-data sources. Concrete implementations
41
+ # MUST provide a `get_overrides_sync` method returning a
42
+ # `Hash{String => SettingValue}` (or nil to indicate no sync data).
43
+ class OverrideDataSource
44
+ def get_overrides_sync
45
+ raise NotImplementedError, "subclasses must implement #get_overrides_sync"
46
+ end
47
+ end
48
+
49
+ # In-memory dict-backed override source — the production default.
50
+ #
51
+ # Converts each primitive value to a `SettingValue` slot once at
52
+ # construction time and returns the same map on every call (stable
53
+ # identity).
54
+ class MapOverrideDataSource < OverrideDataSource
55
+ def initialize(overrides)
56
+ super()
57
+ raise ArgumentError, "overrides must be a Hash" unless overrides.is_a?(Hash)
58
+
59
+ @map = overrides.each_with_object({}) do |(key, value), acc|
60
+ acc[key.to_s] = self.class.to_setting_value(value)
61
+ end.freeze
62
+ freeze
63
+ end
64
+
65
+ def get_overrides_sync
66
+ @map
67
+ end
68
+
69
+ # Map a primitive override value to its SettingValue slot.
70
+ #
71
+ # `TrueClass` / `FalseClass` are checked before `Integer` because
72
+ # in Ruby `true.is_a?(Integer)` is false (unlike Python), so order
73
+ # is purely for clarity here, but we keep the explicit ordering to
74
+ # mirror Python's defensive coding.
75
+ def self.to_setting_value(value)
76
+ case value
77
+ when true, false
78
+ SettingValue.new(b: value)
79
+ when Integer
80
+ SettingValue.new(i: value)
81
+ when Float
82
+ SettingValue.new(d: value)
83
+ when String
84
+ SettingValue.new(s: value)
85
+ else
86
+ raise ArgumentError,
87
+ "Unsupported override value type: #{value.class}. " \
88
+ "Supported types are TrueClass/FalseClass, Integer, Float, String."
89
+ end
90
+ end
91
+ end
92
+
93
+ # Full-config override source — holds a complete parsed `config_v6`
94
+ # blob (`{ "p" => ..., "f" => ..., "s" => ... }`) and routes through
95
+ # the real evaluator offline.
96
+ #
97
+ # Spec 135 / #431 (epic #382, US3): the rule-bearing sibling of
98
+ # `MapOverrideDataSource`. Where the map source emits value-only
99
+ # `SettingValue` slots, this source retains the WHOLE `{ p, f, s }`
100
+ # config — salt (`p.s`) + segments (`s`) + rule-bearing settings (`f`)
101
+ # — so the Ruby evaluator can resolve targeting rules / segments /
102
+ # prerequisites / percentage options purely from the supplied config,
103
+ # with no network fetch.
104
+ #
105
+ # Additive extension (Principle II): ConfigCat's official Ruby SDK only
106
+ # exposes a *file-based* full-config override (`LocalFileDataSource`)
107
+ # plus a value-only `LocalDictionaryDataSource`. This in-memory
108
+ # full-config form is the runtime-agnostic equivalent, mirroring the
109
+ # JS federation's `ConfigOverrideDataSource` / the Python +
110
+ # .NET ports. No ConfigCat-named member is renamed or retyped.
111
+ #
112
+ # The whole config Hash is retained (never just `f`) — the spec-129
113
+ # dropped-salt regression guard (INV-4): percentage bucketing reads
114
+ # `p.s` and segment conditions read `s`.
115
+ #
116
+ # This source intentionally does NOT subclass `OverrideDataSource`'s
117
+ # value-only `get_overrides_sync` contract semantics (its settings are
118
+ # rule-bearing config records, not flat `SettingValue` envelopes). The
119
+ # override router (`Internal::OverrideEval.evaluate`) dispatches on the
120
+ # source TYPE — a `ConfigOverrideDataSource` always routes through the
121
+ # evaluator, never the flat-map path — so it is never mis-read as
122
+ # value-only.
123
+ class ConfigOverrideDataSource < OverrideDataSource
124
+ # @param config [Hash] the full parsed `config_v6` blob (`{ p, f, s }`).
125
+ def initialize(config)
126
+ super()
127
+ raise ArgumentError, "config must be a Hash" unless config.is_a?(Hash)
128
+
129
+ @config = config
130
+ freeze
131
+ end
132
+
133
+ # The full parsed `config_v6` blob (`{ p, f, s }`).
134
+ attr_reader :config
135
+
136
+ # Returns the rule-bearing settings map (`f`).
137
+ #
138
+ # Exposed so `Snapshot#get_all_keys` / `Client#get_all_values` can
139
+ # enumerate the override's flag keys. Resolution itself never goes
140
+ # through this map — the router evaluates the full config through the
141
+ # real evaluator.
142
+ def get_overrides_sync
143
+ settings = @config["f"]
144
+ settings.is_a?(Hash) ? settings : {}
145
+ end
146
+ end
147
+
148
+ # (data_source, behaviour) pair passed via the client constructor.
149
+ FlagOverrides = Struct.new(:data_source, :behaviour, keyword_init: true) do
150
+ def initialize(data_source:, behaviour:)
151
+ unless OverrideBehaviour::ALL.include?(behaviour)
152
+ raise ArgumentError,
153
+ "behaviour must be one of OverrideBehaviour::LOCAL_ONLY, " \
154
+ "::LOCAL_OVER_REMOTE, ::REMOTE_OVER_LOCAL; got #{behaviour.inspect}"
155
+ end
156
+ super
157
+ freeze
158
+ end
159
+ end
160
+
161
+ # Build a `FlagOverrides` from a full `config_v6` blob.
162
+ #
163
+ # The in-memory, rule-bearing offline-override factory (spec 135 /
164
+ # #431). With `OverrideBehaviour::LOCAL_ONLY`,
165
+ # `get_value(key, default, user)` resolves targeting rules / segments /
166
+ # prerequisites / percentage options purely from `config` through the
167
+ # real evaluator — no network fetch.
168
+ #
169
+ # Additive (Principle II): mirrors the JS federation's
170
+ # `createFlagOverridesFromConfig` and the Python / .NET ports.
171
+ # ConfigCat's official Ruby SDK exposes the value-only
172
+ # `LocalDictionaryDataSource` + the file-based `LocalFileDataSource`;
173
+ # this is the in-memory full-config equivalent, so a customer can drive
174
+ # the full rule engine from a config blob already resident in memory
175
+ # (the corpus / test-harness use case) on any runtime.
176
+ #
177
+ # @param config [Hash] the full parsed `config_v6` blob (`{ p, f, s }`).
178
+ # @param behaviour [Symbol] one of `OverrideBehaviour::*`.
179
+ # @return [FlagOverrides]
180
+ def self.create_flag_overrides_from_config(config, behaviour)
181
+ FlagOverrides.new(
182
+ data_source: ConfigOverrideDataSource.new(config),
183
+ behaviour: behaviour
184
+ )
185
+ end
186
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Confeature
4
+ # Subscribe to client lifecycle events. Mirrors ConfigCat's
5
+ # `ConfigCat::Hooks` 1:1 at the public API surface (constitution
6
+ # Principle II, epic #268).
7
+ #
8
+ # Four hook points map to the JS-family reference contract:
9
+ # `client_ready`, `config_changed`, `flag_evaluated`, `error`.
10
+ # Listener exceptions are caught and logged — never escape to the
11
+ # emitter. Obtain via `Confeature::Client#hooks`.
12
+ class Hooks
13
+ def initialize(logger: nil)
14
+ @logger = logger || Confeature.default_logger
15
+ @lock = Mutex.new
16
+ @on_client_ready = []
17
+ @on_config_changed = []
18
+ @on_flag_evaluated = []
19
+ @on_error = []
20
+ end
21
+
22
+ # Subscribe to the first successful config load.
23
+ def add_on_client_ready(&block)
24
+ raise ArgumentError, "block required" unless block_given?
25
+
26
+ @lock.synchronize { @on_client_ready << block }
27
+ nil
28
+ end
29
+
30
+ # Subscribe to every fetched config (`config` is the settings hash).
31
+ def add_on_config_changed(&block)
32
+ raise ArgumentError, "block required" unless block_given?
33
+
34
+ @lock.synchronize { @on_config_changed << block }
35
+ nil
36
+ end
37
+
38
+ # Subscribe to every `EvaluationDetails` produced by `get_value_details`.
39
+ def add_on_flag_evaluated(&block)
40
+ raise ArgumentError, "block required" unless block_given?
41
+
42
+ @lock.synchronize { @on_flag_evaluated << block }
43
+ nil
44
+ end
45
+
46
+ # Subscribe to non-fatal SDK errors (HTTP failures, cache I/O).
47
+ def add_on_error(&block)
48
+ raise ArgumentError, "block required" unless block_given?
49
+
50
+ @lock.synchronize { @on_error << block }
51
+ nil
52
+ end
53
+
54
+ # --- internal invokers ----------------------------------------------
55
+
56
+ def invoke_on_client_ready
57
+ callbacks = @lock.synchronize { @on_client_ready.dup }
58
+ callbacks.each { |cb| safe_call(cb, :on_client_ready) }
59
+ end
60
+
61
+ def invoke_on_config_changed(config)
62
+ callbacks = @lock.synchronize { @on_config_changed.dup }
63
+ callbacks.each { |cb| safe_call(cb, :on_config_changed, config) }
64
+ end
65
+
66
+ def invoke_on_flag_evaluated(details)
67
+ callbacks = @lock.synchronize { @on_flag_evaluated.dup }
68
+ callbacks.each { |cb| safe_call(cb, :on_flag_evaluated, details) }
69
+ end
70
+
71
+ def invoke_on_error(message)
72
+ callbacks = @lock.synchronize { @on_error.dup }
73
+ callbacks.each { |cb| safe_call(cb, :on_error, message) }
74
+ end
75
+
76
+ private
77
+
78
+ def safe_call(callback, name, *args)
79
+ callback.call(*args)
80
+ rescue StandardError => e
81
+ @logger.error("Hooks: #{name} listener raised: #{e.class}: #{e.message}")
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ require "fileutils"
6
+ require "json"
7
+
8
+ require_relative "sdk_key"
9
+
10
+ module Confeature
11
+ module Internal
12
+ # Filesystem + memory cache for the latest `config_v6.json` body
13
+ # + ETag.
14
+ #
15
+ # Default cache dir: `ENV["XDG_CACHE_HOME"] || File.join(Dir.home,
16
+ # ".cache")` joined with `confeature`. Customers override via the
17
+ # client's `cache_directory:` kwarg (FR-014). When the cache
18
+ # directory is not writable, the implementation emits a single
19
+ # WARNING log entry and falls back to a process-local memory cache
20
+ # without further filesystem attempts (FR-012).
21
+ module Cache
22
+ CacheEntry = Struct.new(:body, :etag, :fetched_at, keyword_init: true)
23
+
24
+ def self.now_seconds
25
+ Time.now.to_f
26
+ end
27
+
28
+ # Default platform cache dir resolution. Mirrors the
29
+ # XDG-base-dir spec: prefer XDG_CACHE_HOME if set + non-empty,
30
+ # else `~/.cache`. Returns nil if Dir.home is unavailable.
31
+ def self.default_cache_dir
32
+ xdg = ENV.fetch("XDG_CACHE_HOME", nil)
33
+ base = xdg && !xdg.empty? ? xdg : File.join(Dir.home, ".cache")
34
+ File.join(base, "confeature")
35
+ rescue ArgumentError
36
+ # Dir.home raises ArgumentError when HOME is unset. Treat that
37
+ # as "no filesystem cache available" — caller falls back to
38
+ # memory.
39
+ nil
40
+ end
41
+
42
+ # Abstract base — subclasses provide read / write.
43
+ class ConfigCache
44
+ def read
45
+ raise NotImplementedError, "subclasses must implement #read"
46
+ end
47
+
48
+ def write(_entry)
49
+ raise NotImplementedError, "subclasses must implement #write"
50
+ end
51
+ end
52
+
53
+ # In-process cache — also the fallback when the filesystem
54
+ # refuses.
55
+ class InMemoryCache < ConfigCache
56
+ def initialize
57
+ super
58
+ @entry = nil
59
+ @mutex = Mutex.new
60
+ end
61
+
62
+ def read
63
+ @mutex.synchronize { @entry }
64
+ end
65
+
66
+ def write(entry)
67
+ @mutex.synchronize { @entry = entry }
68
+ end
69
+ end
70
+
71
+ # Persists CacheEntry as JSON at
72
+ # `{cache_dir}/confeature-{cache_key_tail}.json`.
73
+ #
74
+ # On any IO error during write, switches the instance into
75
+ # memory-only mode and emits a single WARNING log entry.
76
+ # Subsequent reads/writes use the in-memory entry — the
77
+ # filesystem is never retried (per FR-012).
78
+ class FilesystemCache < ConfigCache
79
+ attr_reader :file_path
80
+
81
+ def initialize(cache_dir:, cache_key:, sdk_key:, logger:)
82
+ super()
83
+ @sdk_key = sdk_key
84
+ @logger = logger
85
+ cache_key_tail = cache_key.split(":").last
86
+ @cache_dir = cache_dir
87
+ @file_path = File.join(cache_dir, "confeature-#{cache_key_tail}.json")
88
+ @memory = InMemoryCache.new
89
+ @fallback_triggered = false
90
+ @mutex = Mutex.new
91
+ end
92
+
93
+ def read
94
+ return @memory.read if @fallback_triggered
95
+ return @memory.read unless File.exist?(@file_path)
96
+
97
+ begin
98
+ payload = JSON.parse(File.read(@file_path, encoding: "UTF-8"))
99
+ rescue StandardError => e
100
+ @logger.warning(
101
+ "FilesystemCache.read: failed to load #{@file_path} " \
102
+ "(sdkKey=#{SdkKey.redact(@sdk_key)}): #{e.class}"
103
+ )
104
+ return @memory.read
105
+ end
106
+
107
+ begin
108
+ CacheEntry.new(
109
+ body: payload.fetch("body"),
110
+ etag: payload["etag"],
111
+ fetched_at: payload["fetched_at"].to_f
112
+ )
113
+ rescue StandardError => e
114
+ @logger.warning(
115
+ "FilesystemCache.read: malformed cache file #{@file_path} " \
116
+ "(sdkKey=#{SdkKey.redact(@sdk_key)}): #{e.class}"
117
+ )
118
+ nil
119
+ end
120
+ end
121
+
122
+ def write(entry)
123
+ @memory.write(entry)
124
+ return if @fallback_triggered
125
+
126
+ @mutex.synchronize do
127
+ FileUtils.mkdir_p(@cache_dir)
128
+ payload = JSON.generate(
129
+ "body" => entry.body,
130
+ "etag" => entry.etag,
131
+ "fetched_at" => entry.fetched_at
132
+ )
133
+ tmp = "#{@file_path}.tmp"
134
+ File.write(tmp, payload, encoding: "UTF-8")
135
+ File.rename(tmp, @file_path)
136
+ end
137
+ rescue SystemCallError, IOError => e
138
+ @fallback_triggered = true
139
+ @logger.warning(
140
+ "FilesystemCache.write: filesystem unwritable for #{@file_path} " \
141
+ "(sdkKey=#{SdkKey.redact(@sdk_key)}): #{e.class}. " \
142
+ "Falling back to memory-only cache for this client."
143
+ )
144
+ end
145
+ end
146
+
147
+ # Factory: build the right cache flavour for the supplied config.
148
+ # `cache_directory == :memory` (or `nil` when default unavailable)
149
+ # returns InMemoryCache; otherwise FilesystemCache rooted at the
150
+ # supplied or default directory.
151
+ def self.build(cache_directory:, cache_key:, sdk_key:, logger:)
152
+ return InMemoryCache.new if cache_directory == :memory
153
+
154
+ dir = cache_directory || default_cache_dir
155
+ return InMemoryCache.new if dir.nil?
156
+
157
+ FilesystemCache.new(
158
+ cache_dir: dir,
159
+ cache_key: cache_key,
160
+ sdk_key: sdk_key,
161
+ logger: logger
162
+ )
163
+ end
164
+ end
165
+ end
166
+ end