dials 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,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # Per-process, in-memory snapshot cache.
5
+ #
6
+ # Reads never query the store per-dial: they read from the current
7
+ # Snapshot. The snapshot is (re)built from the store on first use, and
8
+ # thereafter freshness is maintained two ways:
9
+ #
10
+ # 1. Local writes bust the cache immediately — the process that made a
11
+ # change reads its own write on the next get.
12
+ # 2. Other processes converge via a throttled staleness probe: at most
13
+ # once per `ttl` seconds, a read asks the store for its version (a
14
+ # single cheap query) and rebuilds only when it moved.
15
+ #
16
+ # ttl = 0 probes on every read (strong consistency, one extra query per
17
+ # read); ttl = nil never probes (bust!/reload! only). Default is 5 seconds.
18
+ #
19
+ # Concurrency rules (each one is a production lesson, not a style choice):
20
+ #
21
+ # - The store is NEVER queried while holding a lock. Holding a mutex
22
+ # across a database call can deadlock a multi-threaded server: the
23
+ # builder waits for a pooled connection while every connection is held
24
+ # by threads blocked on the mutex.
25
+ # - A stale refresh is single-flight (try_lock); threads that lose the
26
+ # race serve the still-valid current snapshot instead of stampeding
27
+ # the database. The probe timestamp is claimed BEFORE the version
28
+ # query, so a burst of readers crossing the TTL boundary doesn't fan
29
+ # out into a probe stampede either.
30
+ # - A bust! that lands while a rebuild is in flight WINS: the rebuild
31
+ # read state before the bust's write, so publishing it would hide that
32
+ # write for a full TTL. The generation counter detects this and drops
33
+ # the stale publish (the requesting reader still gets the built
34
+ # snapshot; the next reader rebuilds fresh).
35
+ # - Once a snapshot has EVER been built, probe and rebuild failures
36
+ # serve last-known-good (with a warning) rather than raising — a
37
+ # database blip must not take down every dial read, including the read
38
+ # right after a local write busted the current snapshot. Only a cold
39
+ # process that has never built one raises: nothing honest exists to
40
+ # serve there.
41
+ class Cache
42
+ def initialize(store:, ttl: 5.0)
43
+ @store = store
44
+ @ttl = ttl
45
+ @build_mutex = Mutex.new
46
+ @state_mutex = Mutex.new # guards @generation/@snapshot writes; never held across store calls
47
+ @snapshot = nil
48
+ @last_good = nil
49
+ @probed_at = nil
50
+ @generation = 0
51
+ end
52
+
53
+ attr_accessor :ttl
54
+
55
+ def snapshot
56
+ current = @snapshot
57
+ return build_or_last_good if current.nil?
58
+ return current unless probe_due?
59
+
60
+ # Claim the probe slot up front: concurrent readers crossing the TTL
61
+ # boundary see a fresh timestamp and skip their own probes.
62
+ @probed_at = monotonic_now
63
+
64
+ begin
65
+ return current if @store.version == current.version
66
+ rescue StandardError => e
67
+ warn "[dials] staleness probe failed; serving the cached snapshot (#{e.class}: #{e.message})"
68
+ return current
69
+ end
70
+
71
+ refresh(current)
72
+ end
73
+
74
+ # A fresh, UNPUBLISHED snapshot straight from the store. Used for reads
75
+ # that must not pollute (or be served from) the shared cache — e.g. a
76
+ # thread that wrote a dial inside a still-open database transaction and
77
+ # must see its own uncommitted state without leaking it to other threads.
78
+ def uncached_snapshot
79
+ Snapshot.new(**@store.state)
80
+ end
81
+
82
+ # Busting discards the published snapshot but NOT the last-known-good
83
+ # copy: if the rebuild after a write fails, reads degrade to slightly
84
+ # stale values instead of exceptions.
85
+ def bust!
86
+ @state_mutex.synchronize do
87
+ @generation += 1
88
+ @snapshot = nil
89
+ @probed_at = nil
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def probe_due?
96
+ return false if @ttl.nil?
97
+ return true if @ttl.zero?
98
+
99
+ last = @probed_at
100
+ last.nil? || (monotonic_now - last) >= @ttl
101
+ end
102
+
103
+ # No published snapshot (cold start, or just busted by a write): build,
104
+ # and on failure fall back to the last snapshot this process ever built —
105
+ # a database blip right after a write must not turn every dial read into
106
+ # an exception. A truly cold process (nothing ever built) raises.
107
+ def build_or_last_good
108
+ build_and_publish
109
+ rescue StandardError => e
110
+ last = @last_good
111
+ raise if last.nil?
112
+
113
+ warn "[dials] snapshot rebuild failed; serving last-known-good (#{e.class}: #{e.message})"
114
+ last
115
+ end
116
+
117
+ # Build without any lock. Concurrent cold readers each build once (a
118
+ # bounded, once-per-boot herd against one small table); the last
119
+ # assignment wins with a valid snapshot either way.
120
+ def build_and_publish
121
+ generation = @state_mutex.synchronize { @generation }
122
+ built = Snapshot.new(**@store.state)
123
+
124
+ # Publish only if no bust! landed while we were reading the store —
125
+ # otherwise this snapshot predates a write and must not become the
126
+ # shared state. Check and assignment share the state mutex so a bust!
127
+ # cannot slip between them. The caller still gets the built snapshot.
128
+ # Either way the build becomes last-known-good: even a snapshot that
129
+ # predates a concurrent write is honest data — exactly what LKG serves.
130
+ @state_mutex.synchronize do
131
+ @last_good = built
132
+ if generation == @generation
133
+ @probed_at = monotonic_now
134
+ @snapshot = built
135
+ end
136
+ end
137
+
138
+ built
139
+ end
140
+
141
+ def refresh(current)
142
+ return current unless @build_mutex.try_lock
143
+
144
+ begin
145
+ build_and_publish
146
+ rescue StandardError => e
147
+ warn "[dials] snapshot rebuild failed; serving the cached snapshot (#{e.class}: #{e.message})"
148
+ current
149
+ ensure
150
+ @build_mutex.unlock
151
+ end
152
+ end
153
+
154
+ def monotonic_now
155
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # One entry in the change log, in a store-independent shape. `scope` is nil
5
+ # for global changes; `old_value` is nil when no override existed before
6
+ # (the write introduced the override); `new_value` is nil for clears.
7
+ #
8
+ # The change log is append-only and doubles as the store's version counter,
9
+ # so every mutation the public API performs lands here by construction.
10
+ ChangeRecord = Data.define(:key, :scope, :action, :old_value, :new_value,
11
+ :actor_type, :actor_id, :actor_label, :created_at) do
12
+ def global?
13
+ scope.nil?
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # App-wide configuration, set once at boot via Dials.configure.
5
+ class Config
6
+ # Seconds between staleness probes (see Cache). 0 probes every read;
7
+ # nil never probes.
8
+ attr_reader :cache_ttl
9
+
10
+ # Builds the human label stored on every change-log entry.
11
+ attr_accessor :actor_label
12
+
13
+ # Fallback attribution for writes that pass no actor: — for apps without
14
+ # user identity (no User model, single-operator tools, scripts). A
15
+ # string/object, or a callable evaluated per write
16
+ # (`-> { ENV.fetch("USER", "console") }`). nil (the default) keeps
17
+ # actor: required on every write. This is a declared app-level fallback,
18
+ # not discovery — the gem still never guesses (no Current.user magic),
19
+ # and an explicit actor: always wins.
20
+ attr_accessor :default_actor
21
+
22
+ def initialize
23
+ @store = nil
24
+ @cache_ttl = 5.0
25
+ @actor_label = Actor::DEFAULT_LABEL
26
+ @default_actor = nil
27
+ end
28
+
29
+ def cache_ttl=(seconds)
30
+ @cache_ttl = seconds
31
+ Dials.cache.ttl = seconds
32
+ end
33
+
34
+ # Accepts a store instance, or the symbols :memory / :active_record.
35
+ def store=(store)
36
+ @store = case store
37
+ when :memory then Stores::Memory.new
38
+ when :active_record
39
+ require "dials/active_record"
40
+ Stores::ActiveRecordStore.new
41
+ else store
42
+ end
43
+ Dials.reset_cache!
44
+ end
45
+
46
+ def store
47
+ @store ||= Stores::Memory.new
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # A dial's declaration: its identity, its code default, and the rules a
5
+ # stored value must satisfy. Definitions live in code (the registry), never
6
+ # in the database — the database stores only values.
7
+ #
8
+ # type:: :boolean, :integer, :float, :string, or :json (any
9
+ # JSON-serializable structure).
10
+ # constraints:: optional keywords on top of the type, in JSON Schema's
11
+ # vocabulary — `minimum: 1, maximum: 10_000`, `enum: %w[low high]`,
12
+ # `pattern: /.../`, `properties:`/`required:` for :json objects.
13
+ # See Schema for the full keyword set per type.
14
+ # validate:: optional callable returning truthy when a value is storable —
15
+ # the escape hatch for rules a schema cannot express. Unlike the
16
+ # schema keywords it cannot be rendered or serialized; prefer the
17
+ # keywords whenever they can say it.
18
+ # dimensions:: the axes the dial can vary along (per market, per
19
+ # platform, ...). Declaring dimensions is the arming gate: a dial
20
+ # with none is global-only by construction, and adding the
21
+ # declaration belongs in the same change as the code that reads
22
+ # the dimensioned value.
23
+ class Definition
24
+ TYPES = %i[boolean integer float string json].freeze
25
+
26
+ # Keys land in an indexed VARCHAR(100) that shares a composite unique
27
+ # index with the 255-byte canonical scope; the explicit cap keeps that
28
+ # index inside every supported database's budget (and generated method
29
+ # names sane).
30
+ MAX_KEY_LENGTH = 100
31
+
32
+ # Keys become method names (the bare reader, adjust_<key>, clear_<key>),
33
+ # so they must be plain callable identifiers — no spaces, hyphens,
34
+ # question marks, or leading digits.
35
+ KEY_FORMAT = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
36
+
37
+ attr_reader :key, :default, :type, :label, :unit, :description, :dimensions, :schema
38
+
39
+ def initialize(key, default:, type:, label: nil, unit: nil, description: nil,
40
+ dimensions: nil, validate: nil, **constraints)
41
+ @key = key.to_sym
42
+ @type = type.to_sym
43
+ @label = label || @key.to_s.tr("_", " ").capitalize
44
+ @unit = unit
45
+ @description = description
46
+ @validate = validate
47
+
48
+ # Type first: Schema derives its allowed keywords from it.
49
+ raise InvalidDefinition, "#{@key}: unknown type #{@type.inspect} (use one of #{TYPES.join(', ')})" unless TYPES.include?(@type)
50
+
51
+ @schema = Schema.new(@key, @type, constraints)
52
+ @dimensions = build_dimensions(dimensions)
53
+ @default = default
54
+
55
+ # Validate BEFORE freezing: a default that fails validation (including
56
+ # a cyclic structure, which the JSON round-trip rejects) must raise
57
+ # InvalidDefinition without having frozen the caller's object — and
58
+ # Freeze.deep would recurse forever on a cycle.
59
+ validate_definition!
60
+
61
+ # Deep-frozen: resolution returns the default directly when nothing is
62
+ # stored, and a caller must not be able to mutate the code default for
63
+ # every other reader in the process.
64
+ @default = Freeze.deep(default)
65
+ freeze
66
+ end
67
+
68
+ def dimensions?
69
+ !dimensions.empty?
70
+ end
71
+
72
+ def dimension_names
73
+ dimensions.map(&:name)
74
+ end
75
+
76
+ # The declaration as a JSON Schema fragment — what an admin surface (or
77
+ # an agent reading the dial catalog) needs to render inputs and validate
78
+ # client-side. A `validate:` callable is not representable and is simply
79
+ # absent; the server-side check still runs on every write.
80
+ def to_json_schema
81
+ out = {}
82
+ out["type"] = json_schema_type if json_schema_type
83
+ out["title"] = label
84
+ out["description"] = description if description
85
+ out.merge!(schema.to_json_schema)
86
+ out["default"] = default
87
+ out
88
+ end
89
+
90
+ # Validation problems for a candidate stored value; [] when storable.
91
+ # `nil` is never storable — removing an override is a clear, so a stored
92
+ # nil could only ever be an accident.
93
+ def problems_for(value)
94
+ return ["cannot be nil (use clear to remove an override)"] if value.nil?
95
+
96
+ problem = type_problem(value)
97
+ return [problem] if problem
98
+
99
+ problems = schema.problems_for(value)
100
+ problems << "fails its validate check" if @validate && !@validate.call(value)
101
+ problems
102
+ end
103
+
104
+ def validate_value!(value)
105
+ problems = problems_for(value)
106
+ return value if problems.empty?
107
+
108
+ raise InvalidValue, "#{key}: value #{value.inspect} #{problems.join('; ')}"
109
+ end
110
+
111
+ private
112
+
113
+ def json_schema_type
114
+ case type
115
+ when :float then "number"
116
+ when :json then schema.object? ? "object" : nil
117
+ else type.to_s
118
+ end
119
+ end
120
+
121
+ def build_dimensions(declared)
122
+ case declared
123
+ when nil then [].freeze
124
+ when Array
125
+ declared.map { |name| Dimension.new(name) }.freeze
126
+ when Hash
127
+ declared.map { |name, spec| Dimension.new(name, enum: dimension_enum(name, spec)) }.freeze
128
+ else
129
+ raise InvalidDefinition, "#{key}: dimensions must be a Hash or Array, got #{declared.class}"
130
+ end
131
+ end
132
+
133
+ # Strict on shape: a typo like `{ "enum" => [...] }` (string key) or
134
+ # `{ market: "KE" }` must raise, not silently become an OPEN dimension
135
+ # that accepts any value.
136
+ def dimension_enum(name, spec)
137
+ case spec
138
+ when nil then nil
139
+ when Hash
140
+ unknown = spec.keys - [:enum]
141
+ unless unknown.empty?
142
+ raise InvalidDefinition,
143
+ "#{key}: dimension #{name} has unknown keys #{unknown.inspect} (use enum: with a symbol key)"
144
+ end
145
+ spec[:enum]
146
+ when Array then spec
147
+ else
148
+ return spec if spec.respond_to?(:call)
149
+
150
+ raise InvalidDefinition, "#{key}: dimension #{name} spec must be an Array, a callable, or { enum: ... }"
151
+ end
152
+ end
153
+
154
+ def validate_definition!
155
+ if key.length > MAX_KEY_LENGTH
156
+ raise InvalidDefinition, "#{key}: key exceeds #{MAX_KEY_LENGTH} characters"
157
+ end
158
+ unless KEY_FORMAT.match?(key.to_s)
159
+ raise InvalidDefinition,
160
+ "#{key.inspect}: key must be a plain identifier (letters, digits, underscores; " \
161
+ "it becomes the dial's method names)"
162
+ end
163
+
164
+ names = dimension_names
165
+ raise InvalidDefinition, "#{key}: duplicate dimension" unless names.uniq.length == names.length
166
+
167
+ # Generated adjust_/clear_ methods take scope as bare keywords next to
168
+ # actor: and expected_version:, so dimensions by those names could
169
+ # never be passed to them.
170
+ if names.include?(:actor)
171
+ raise InvalidDefinition, "#{key}: actor is a reserved dimension name (it means attribution on every write)"
172
+ end
173
+ if names.include?(:expected_version)
174
+ raise InvalidDefinition,
175
+ "#{key}: expected_version is a reserved dimension name (it means stale-write protection on every write)"
176
+ end
177
+
178
+ if @validate && !@validate.respond_to?(:call)
179
+ raise InvalidDefinition, "#{key}: validate must be a callable"
180
+ end
181
+
182
+ problems = problems_for(default)
183
+ return if problems.empty?
184
+
185
+ raise InvalidDefinition, "#{key}: default #{default.inspect} #{problems.join('; ')}"
186
+ end
187
+
188
+ def type_problem(value)
189
+ case type
190
+ when :boolean
191
+ # `false` is a first-class storable value. Anything presence-shaped
192
+ # that rejects false makes a kill switch impossible to turn off.
193
+ "must be true or false" unless [true, false].include?(value)
194
+ when :integer
195
+ "must be an integer" unless value.is_a?(Integer)
196
+ when :float
197
+ # Only Integer and Float survive a JSON round-trip as numbers —
198
+ # BigDecimal and Rational would come back from the store as strings.
199
+ # Non-finite floats (NaN, Infinity) are not representable in JSON.
200
+ if !(value.is_a?(Integer) || value.is_a?(Float))
201
+ "must be an Integer or Float"
202
+ elsif value.is_a?(Float) && !value.finite?
203
+ "must be finite"
204
+ end
205
+ when :string
206
+ "must be a string" unless value.is_a?(String)
207
+ when :json
208
+ json_problem(value)
209
+ end
210
+ end
211
+
212
+ # A :json value must survive the JSON round-trip UNCHANGED. Ruby's JSON
213
+ # generator happily stringifies symbols, Times, and arbitrary objects —
214
+ # which means a write would succeed and the very next read would return
215
+ # a different value. Requiring round-trip equality rejects those at
216
+ # write time (use string keys and JSON-native types).
217
+ def json_problem(value)
218
+ decoded = JSON.parse(JSON.generate(value))
219
+ return nil if decoded == value
220
+
221
+ "must round-trip through JSON unchanged (use string keys and JSON-native types)"
222
+ rescue StandardError
223
+ "must be JSON-serializable"
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # One dimension of a dial: a name (:market, :platform, ...) and an
5
+ # optional set of allowed values — `enum`, the same word JSON Schema and
6
+ # the dial value constraints use. The enum may be given as an Array or as a
7
+ # callable (for values that are expensive to build or defined elsewhere,
8
+ # e.g. `-> { ISO3166::Country.codes }`); a callable is resolved once, on
9
+ # first use, under a lock (so a stateful or expensive callable cannot be
10
+ # raced into running twice by concurrent first reads).
11
+ #
12
+ # Dimension values are always compared as strings — "KE" and :KE name the
13
+ # same market. A dimension without an enum accepts any non-empty string up
14
+ # to MAX_VALUE_LENGTH characters (canonical scopes land in an indexed
15
+ # VARCHAR column; unbounded values would overflow or collide there).
16
+ class Dimension
17
+ MAX_VALUE_LENGTH = 128
18
+
19
+ attr_reader :name
20
+
21
+ def initialize(name, enum: nil)
22
+ @name = name.to_sym
23
+ @raw_enum = enum
24
+ @enum = nil
25
+ @mutex = Mutex.new
26
+ validate_shape!
27
+ end
28
+
29
+ # Allowed values as an Array of strings, or nil when the dimension is
30
+ # open (accepts any value).
31
+ def enum
32
+ return nil if @raw_enum.nil?
33
+
34
+ # Double-checked so the hot read path (every scope validation) skips
35
+ # the mutex once resolved.
36
+ resolved = @enum
37
+ return resolved if resolved
38
+
39
+ @mutex.synchronize do
40
+ @enum ||= Array(@raw_enum.respond_to?(:call) ? @raw_enum.call : @raw_enum)
41
+ .map { |o| o.to_s.freeze }.freeze
42
+ end
43
+ end
44
+
45
+ def valid_value?(value)
46
+ value = value.to_s
47
+ return false if value.empty? || value.length > MAX_VALUE_LENGTH
48
+
49
+ enum.nil? || enum.include?(value)
50
+ end
51
+
52
+ private
53
+
54
+ def validate_shape!
55
+ return if @raw_enum.nil? || @raw_enum.is_a?(Array) || @raw_enum.respond_to?(:call)
56
+
57
+ raise InvalidDefinition, "dimension #{@name}: enum must be an Array or a callable, got #{@raw_enum.class}"
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # Base class for every error the gem raises deliberately.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when a key is read or written that no `dial` declaration defined.
8
+ class UnknownDial < Error; end
9
+
10
+ # Raised when the same key is declared twice. A dial's declaration is its
11
+ # identity; a silent second declaration would make "which bounds apply?"
12
+ # ambiguous.
13
+ class DuplicateDial < Error; end
14
+
15
+ # Raised when a definition itself is malformed (bad type, bad dimensions
16
+ # shape, unknown schema keyword, default that fails its own schema).
17
+ # Definitions fail at boot, not at first read in production.
18
+ class InvalidDefinition < Error; end
19
+
20
+ # Raised when a candidate value is not storable for its dial: wrong type,
21
+ # schema violation, or nil (nil is never a value — use clear to remove an
22
+ # override).
23
+ class InvalidValue < Error; end
24
+
25
+ # Raised when a scope does not match the dial's declared dimensions:
26
+ # unknown dimension, missing dimension, or a value outside a dimension's
27
+ # declared enum. Also raised when a scope is given for a dial that
28
+ # declares no dimensions at all.
29
+ class InvalidScope < Error; end
30
+
31
+ # Raised when a write arrives without an actor. Every write is attributed;
32
+ # there is no anonymous mutation path through the public API.
33
+ class MissingActor < Error; end
34
+
35
+ # Raised when a write carries `expected_version:` and the override it
36
+ # targets has changed (or appeared, or vanished) since that version was
37
+ # read — the caller acted on a stale picture. The write is not applied and
38
+ # nothing is appended to the change log. Deliberately NOT retried by the
39
+ # stores (a retried compare-and-swap would recompute against the new
40
+ # version and succeed, silently defeating the mechanism): the surface
41
+ # should re-render from Dials.overview and let the operator decide again.
42
+ class StaleWrite < Error; end
43
+
44
+ # Raised when concurrent UNCONDITIONAL writes to the same override race
45
+ # each other faster than the store's bounded retries can absorb —
46
+ # essentially never at operator write rates. Safe to retry; carries no
47
+ # staleness meaning (that is StaleWrite).
48
+ class WriteConflict < Error; end
49
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # Recursive freeze for JSON-shaped values (hashes, arrays, scalars). Used on
5
+ # snapshot contents and on declaration defaults, so no code path can hand a
6
+ # caller a mutable reference into shared dial state.
7
+ module Freeze
8
+ module_function
9
+
10
+ def deep(object)
11
+ case object
12
+ when Hash
13
+ object.each { |k, v| deep(k) && deep(v) }
14
+ when Array
15
+ object.each { |v| deep(v) }
16
+ end
17
+ object.freeze
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # Per-dial generated methods. Declaring `dial :base_fee, ...` defines
5
+ #
6
+ # Dials.base_fee(**scope) # read (Dials.get)
7
+ # Dials.adjust_base_fee(value, actor:, **scope) # write (Dials.set)
8
+ # Dials.clear_base_fee(actor:, **scope) # clear (Dials.clear)
9
+ #
10
+ # The reader is the bare dial name — reading is what you do with a dial
11
+ # all day, so it pays no prefix tax; the writers carry their verbs. These
12
+ # are real methods defined at declaration time — never method_missing — so
13
+ # respond_to?, tab completion, and a grep for `base_fee` all work. A dial
14
+ # whose name collides with an existing Dials method (:store, :cache,
15
+ # :changes, ...) fails at boot rather than shadowing the API.
16
+ #
17
+ # Scope travels as bare keywords here (`market: "KE"`), which is why
18
+ # `actor` and `expected_version` are reserved dimension names: on
19
+ # adjust_/clear_ they must always mean attribution and stale-write
20
+ # protection, never scope. Definition enforces the reservation.
21
+ #
22
+ # The methods live on this module (which Dials extends) rather than on
23
+ # Dials directly so Registry#reset! can strip every generated method
24
+ # without touching the core API.
25
+ module Generated
26
+ class << self
27
+ # Define the three methods for a definition. Collisions are checked
28
+ # first — all three names, including against private methods, since a
29
+ # method on Dials itself would shadow anything defined here — so a
30
+ # raise leaves nothing half-installed.
31
+ def install!(definition)
32
+ key = definition.key
33
+ names = [key, :"adjust_#{key}", :"clear_#{key}"]
34
+
35
+ names.each do |name|
36
+ next unless Dials.respond_to?(name, true)
37
+
38
+ raise InvalidDefinition, "dial #{key} would define Dials.#{name}, which already exists"
39
+ end
40
+
41
+ # actor: defaults to nil rather than being a required keyword so that
42
+ # apps declaring config.default_actor can write without one; with no
43
+ # default configured, Actor.normalize still raises MissingActor.
44
+ define_method(names[0]) { |**scope| get(key, **scope) }
45
+ define_method(names[1]) do |value, actor: nil, expected_version: nil, **scope|
46
+ set(key, value, actor: actor, scope: scope, expected_version: expected_version)
47
+ end
48
+ define_method(names[2]) do |actor: nil, expected_version: nil, **scope|
49
+ clear(key, actor: actor, scope: scope, expected_version: expected_version)
50
+ end
51
+ end
52
+
53
+ def uninstall_all!
54
+ instance_methods(false).each { |name| remove_method(name) }
55
+ end
56
+ end
57
+ end
58
+
59
+ extend Generated
60
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # The complete stored state of every registered dial, read from ONE
5
+ # snapshot in one call — so an admin page renders a coherent picture.
6
+ #
7
+ # overview.version # the store's write-clock token (informational:
8
+ # # "rendered as of"; freshness displays, cheap
9
+ # # did-anything-change checks)
10
+ # overview.dials # [DialState, ...] in registry order
11
+ #
12
+ # Stale-write tokens are PER OVERRIDE — see DialState#global_version and
13
+ # #scoped_override_versions; feed those back as `expected_version:` on writes.
14
+ Overview = Data.define(:version, :dials)
15
+
16
+ # One dial's declaration plus its stored overrides at the snapshot moment.
17
+ #
18
+ # `global_override?` is explicit — a boolean dial overridden to `false`
19
+ # must never be confusable with "no override" (`global_value` alone could
20
+ # not distinguish them; it is nil when no global override exists).
21
+ #
22
+ # `global_version` and `scoped_override_versions` are the per-override
23
+ # stale-write tokens: echo the one for the override you are writing as
24
+ # `expected_version:`. Cleared overrides keep a TOMBSTONE token (the
25
+ # stream's clear stamp) — so `scoped_override_versions` can carry entries
26
+ # for scopes with no value in `scoped_overrides`, and `global_version` is
27
+ # Dials::ABSENT_VERSION only when the global was never written at all.
28
+ # For an override with no token listed anywhere, pass
29
+ # Dials::ABSENT_VERSION to assert it has never been written.
30
+ DialState = Data.define(:definition, :global_override, :global_value, :global_version,
31
+ :scoped_overrides, :scoped_override_versions) do
32
+ def key
33
+ definition.key
34
+ end
35
+
36
+ def global_override?
37
+ global_override
38
+ end
39
+
40
+ # The declaration as a JSON Schema fragment (see Definition#to_json_schema).
41
+ def json_schema
42
+ definition.to_json_schema
43
+ end
44
+ end
45
+ end