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,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ module Stores
5
+ # The reference store: plain hashes behind a mutex. It is the default
6
+ # store so the gem works out of the box (and in client test suites)
7
+ # without a database, and it doubles as the executable specification of
8
+ # the store interface:
9
+ #
10
+ # state → { globals:, scoped_overrides:, version:, row_versions: }
11
+ # version → monotonic value, moves on every write
12
+ # override_version(key, canonical) → the stream's stamp (tombstones
13
+ # included); 0 only when never written
14
+ # set_override(key, canonical, value, actor, expected_version: nil) → [previous value or nil, new stamp]
15
+ # clear_override(key, canonical, actor, expected_version: nil) → [true if an override existed, stamp]
16
+ # changes(key: nil, limit: 50) → newest-first [ChangeRecord]
17
+ #
18
+ # `canonical` is always a canonical scope string; Scope::GLOBAL names the
19
+ # global override (the override at the empty scope).
20
+ #
21
+ # `actor` is the normalized hash from Dials::Actor. Value validation and
22
+ # scope validation happen above the store; a store only persists.
23
+ #
24
+ # Concurrency control is per-override optimistic locking: every stream
25
+ # carries a version stamp (here, the write counter at its last write; in
26
+ # the ActiveRecord store, the stream seq — both only ever grow).
27
+ # Tombstones KEEP their stamp, so an absent → set → clear cycle can never
28
+ # make an old "absent" token current again; StoreVersion::ABSENT strictly
29
+ # means "never written". `expected_version:` (an opaque StoreVersion
30
+ # token) compares against THAT stream's stamp atomically with the write;
31
+ # a mismatch raises StaleWrite with nothing applied or logged. The check
32
+ # runs before the existence check on clears: a no-op clear against a
33
+ # stale picture is still stale.
34
+ #
35
+ # Values are round-tripped through JSON on write, exactly like the
36
+ # ActiveRecord store. That buys two guarantees at once: the store never
37
+ # retains a reference to a caller-owned mutable object (mutating a hash
38
+ # after a write cannot silently change the stored override or rewrite
39
+ # change-log history), and both stores return byte-identical shapes
40
+ # (symbol keys become strings here too, so a test suite on the memory
41
+ # store proves what production on ActiveRecord will do).
42
+ class Memory
43
+ def initialize
44
+ @globals = {}
45
+ @scoped = Hash.new { |h, k| h[k] = {} }
46
+ @row_versions = Hash.new { |h, k| h[k] = {} }
47
+ @changes = []
48
+ @version = 0
49
+ @mutex = Mutex.new
50
+ end
51
+
52
+ def state
53
+ @mutex.synchronize do
54
+ {
55
+ globals: @globals.transform_values { |v| dup_value(v) },
56
+ scoped_overrides: @scoped.to_h { |k, scopes| [k, scopes.transform_values { |v| dup_value(v) }] },
57
+ version: @version,
58
+ row_versions: @row_versions.to_h { |k, scopes| [k, scopes.dup] }
59
+ }
60
+ end
61
+ end
62
+
63
+ def version
64
+ @mutex.synchronize { @version }
65
+ end
66
+
67
+ def override_version(key, canonical_scope)
68
+ @mutex.synchronize { row_version(key, canonical_scope) }
69
+ end
70
+
71
+ def set_override(key, canonical_scope, value, actor, expected_version: nil)
72
+ @mutex.synchronize do
73
+ assert_version!(expected_version, row_version(key, canonical_scope))
74
+ stored = roundtrip(value)
75
+ if canonical_scope == Scope::GLOBAL
76
+ old = @globals[key]
77
+ @globals[key] = stored
78
+ record(key, nil, "set", old, stored, actor)
79
+ else
80
+ old = @scoped[key][canonical_scope]
81
+ @scoped[key][canonical_scope] = stored
82
+ record(key, canonical_scope, "set", old, stored, actor)
83
+ end
84
+ [old, stamp(key, canonical_scope)]
85
+ end
86
+ end
87
+
88
+ def clear_override(key, canonical_scope, actor, expected_version: nil)
89
+ @mutex.synchronize do
90
+ current = row_version(key, canonical_scope)
91
+ assert_version!(expected_version, current)
92
+ if canonical_scope == Scope::GLOBAL
93
+ next [false, current] unless @globals.key?(key)
94
+
95
+ old = @globals.delete(key)
96
+ record(key, nil, "clear", old, nil, actor)
97
+ else
98
+ next [false, current] unless @scoped.key?(key) && @scoped[key].key?(canonical_scope)
99
+
100
+ old = @scoped[key].delete(canonical_scope)
101
+ @scoped.delete(key) if @scoped[key].empty?
102
+ record(key, canonical_scope, "clear", old, nil, actor)
103
+ end
104
+ # The tombstone keeps a (new) stamp: "absent because cleared" must
105
+ # never compare equal to "absent because never written".
106
+ [true, stamp(key, canonical_scope)]
107
+ end
108
+ end
109
+
110
+ def changes(key: nil, limit: 50)
111
+ @mutex.synchronize do
112
+ selected = key ? @changes.select { |c| c.key == key.to_sym } : @changes
113
+ selected.last(limit).reverse
114
+ end
115
+ end
116
+
117
+ private
118
+
119
+ # Callers hold the mutex.
120
+ def row_version(key, canonical_scope)
121
+ @row_versions[key][canonical_scope] || 0
122
+ end
123
+
124
+ # Callers hold the mutex. Raises StaleWrite before anything is touched,
125
+ # so the transactionless memory store still guarantees "unapplied and
126
+ # unlogged" on a version mismatch.
127
+ def assert_version!(expected, current)
128
+ return if expected.nil? || expected == StoreVersion.token(current)
129
+
130
+ raise StaleWrite,
131
+ "the override has changed since version #{expected} was read — " \
132
+ "re-read (Dials.overview) and retry deliberately"
133
+ end
134
+
135
+ # Callers hold the mutex; record has already bumped @version, which
136
+ # serves as the stream stamp (the ActiveRecord analog is the seq).
137
+ def stamp(key, canonical_scope)
138
+ @row_versions[key][canonical_scope] = @version
139
+ end
140
+
141
+ # Callers hold the mutex. Old/new values are duplicated (so a change
142
+ # record never shares structure with the live store state) and frozen
143
+ # (so a caller mutating what Dials.changes returned cannot rewrite the
144
+ # retained history — the ActiveRecord store decodes fresh per call and
145
+ # has no equivalent hazard).
146
+ def record(key, canonical_scope, action, old_value, new_value, actor)
147
+ @version += 1
148
+ @changes << ChangeRecord.new(
149
+ key: key,
150
+ scope: canonical_scope && Freeze.deep(Scope.parse(canonical_scope)),
151
+ action: action,
152
+ old_value: Freeze.deep(dup_value(old_value)),
153
+ new_value: Freeze.deep(dup_value(new_value)),
154
+ actor_type: actor[:actor_type],
155
+ actor_id: actor[:actor_id],
156
+ actor_label: actor[:actor_label],
157
+ created_at: Time.now.utc
158
+ )
159
+ end
160
+
161
+ def roundtrip(value)
162
+ JSON.parse(JSON.generate(value))
163
+ end
164
+
165
+ # Containers are deep-duplicated (they are JSON-pure after roundtrip);
166
+ # scalars are safe to share.
167
+ def dup_value(value)
168
+ case value
169
+ when Hash, Array then roundtrip(value)
170
+ else value
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # Test helpers. `with_overrides` pins dial values for the duration of a
5
+ # block without touching the store, the cache, or the change log — reads
6
+ # inside the block (on the same thread) see the pinned value for every
7
+ # scope of that dial. Nesting composes; inner blocks win.
8
+ #
9
+ # Dials::Testing.with_overrides(merchant_fee_bps: 250) do
10
+ # Dials.merchant_fee_bps(market: "KE") # => 250
11
+ # end
12
+ #
13
+ # Values are validated against the dial's declaration, so a test cannot
14
+ # pin a value production could never hold.
15
+ module Testing
16
+ THREAD_KEY = :dials_testing_overrides
17
+
18
+ module_function
19
+
20
+ def with_overrides(overrides)
21
+ validated = overrides.to_h do |key, value|
22
+ definition = Dials.registry.fetch(key)
23
+ [definition.key, definition.validate_value!(value)]
24
+ end
25
+
26
+ previous = Thread.current[THREAD_KEY]
27
+ Thread.current[THREAD_KEY] = (previous || {}).merge(validated)
28
+ yield
29
+ ensure
30
+ Thread.current[THREAD_KEY] = previous
31
+ end
32
+
33
+ def override_for(key)
34
+ overrides = Thread.current[THREAD_KEY]
35
+ return nil unless overrides
36
+
37
+ overrides.key?(key) ? [overrides[key]] : nil
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ VERSION = "0.1.0"
5
+ end
data/lib/dials.rb ADDED
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "dials/version"
6
+ require_relative "dials/errors"
7
+ require_relative "dials/generated"
8
+ require_relative "dials/freeze"
9
+ require_relative "dials/schema"
10
+ require_relative "dials/dimension"
11
+ require_relative "dials/definition"
12
+ require_relative "dials/registry"
13
+ require_relative "dials/scope"
14
+ require_relative "dials/snapshot"
15
+ require_relative "dials/store_version"
16
+ require_relative "dials/overview"
17
+ require_relative "dials/resolver"
18
+ require_relative "dials/cache"
19
+ require_relative "dials/change_record"
20
+ require_relative "dials/actor"
21
+ require_relative "dials/stores/memory"
22
+ require_relative "dials/config"
23
+ require_relative "dials/testing"
24
+
25
+ # Dials: operator-adjustable values with per-scope overrides.
26
+ #
27
+ # A dial is a value that starts life as a code default, can be overridden
28
+ # globally at runtime, and can be overridden again per scope along its
29
+ # declared dimensions (per market, per platform, ...). Resolution is always:
30
+ #
31
+ # scoped override → global override → code default
32
+ #
33
+ # Declarations live in code (Dials.define); values live in a store; reads
34
+ # come from a per-process cache. Every write is attributed and logged.
35
+ #
36
+ # Declaring a dial generates its methods (see Generated):
37
+ #
38
+ # Dials.base_fee(market: "KE") # read
39
+ # Dials.adjust_base_fee(25, actor: ops, market: "KE") # write
40
+ # Dials.clear_base_fee(actor: ops, market: "KE") # remove an override
41
+ #
42
+ # The key-taking primitives (get, set, clear) stay public underneath — they
43
+ # are the dynamic-access layer for code that receives the key at runtime
44
+ # (an admin surface iterating the registry, a console one-liner).
45
+ module Dials
46
+ # Thread-local marker: this thread performed a dial write inside a
47
+ # database transaction that is still open. While set, the thread's reads
48
+ # come from fresh, UNPUBLISHED snapshots — it sees its own uncommitted
49
+ # write, but the uncommitted value never lands in the shared cache (where
50
+ # other threads would read it, and where it would survive a rollback).
51
+ TXN_WRITE_KEY = :dials_wrote_in_open_transaction
52
+
53
+ CACHE_LOCK = Mutex.new
54
+
55
+ # The stale-write token of an override that is not stored. Pass it as
56
+ # `expected_version:` to assert "there was no override here when I looked"
57
+ # — the write succeeds only if that is still true.
58
+ ABSENT_VERSION = StoreVersion::ABSENT
59
+
60
+ class << self
61
+ # -- declaration ---------------------------------------------------------
62
+
63
+ attr_reader :registry, :config
64
+
65
+ # Declare dials:
66
+ #
67
+ # Dials.define do
68
+ # dial :merchant_fee_bps, default: 100, type: :integer,
69
+ # minimum: 1, maximum: 10_000, unit: "bps",
70
+ # dimensions: { market: { enum: %w[KE NG BD] } }
71
+ # dial :signups_enabled, default: true, type: :boolean
72
+ # end
73
+ #
74
+ # Each declaration generates the dial's methods: merchant_fee_bps (the
75
+ # reader), adjust_merchant_fee_bps, clear_merchant_fee_bps (see
76
+ # Generated).
77
+ def define(&)
78
+ registry.instance_eval(&)
79
+ end
80
+
81
+ # -- configuration -------------------------------------------------------
82
+
83
+ def configure
84
+ yield config
85
+ end
86
+
87
+ def store
88
+ config.store
89
+ end
90
+
91
+ def cache
92
+ @cache || CACHE_LOCK.synchronize { @cache ||= Cache.new(store: store, ttl: config.cache_ttl) }
93
+ end
94
+
95
+ # Discard the cache object entirely (used when the store is swapped).
96
+ def reset_cache!
97
+ CACHE_LOCK.synchronize { @cache = nil }
98
+ end
99
+
100
+ # Force the next read to rebuild from the store — e.g. after writing
101
+ # through a console in another process, or in a test. Also clears this
102
+ # thread's in-transaction-write marker (test suites that wrap examples
103
+ # in transactions call this between examples).
104
+ def reload!
105
+ Thread.current[TXN_WRITE_KEY] = nil
106
+ cache.bust!
107
+ end
108
+
109
+ # -- reads ---------------------------------------------------------------
110
+
111
+ # Resolve a dial by key — the primitive under the generated readers,
112
+ # for callers that receive the key at runtime. Scope is passed
113
+ # as keyword arguments and must name every dimension the dial declares —
114
+ # no more, no less:
115
+ #
116
+ # Dials.get(:signups_enabled) # global-only dial
117
+ # Dials.get(:merchant_fee_bps, market: "KE") # varied dial
118
+ #
119
+ # Raises UnknownDial / InvalidScope on misuse; never raises for a merely
120
+ # missing override (that is what defaults are for).
121
+ def get(key, **scope)
122
+ definition = registry.fetch(key)
123
+ normalized = Scope.validate!(definition, scope, exact: true)
124
+
125
+ # After scope validation, so a test override can never mask a read that
126
+ # would raise in production.
127
+ pinned = Testing.override_for(definition.key)
128
+ return pinned.first if pinned
129
+
130
+ Resolver.resolve(definition, normalized, current_snapshot)
131
+ end
132
+
133
+ # One dial's stored scoped overrides as { parsed scope => value }, e.g.
134
+ # { { market: "BD" } => 24, { market: "NG" } => 48 } — "which markets
135
+ # override this dial?". Scopes come back as parsed hashes, never
136
+ # canonical scope strings. A dial with nothing scoped stored (or no
137
+ # dimensions at all) returns {}; the global override is not included
138
+ # (see overview). Reads from the same snapshot path as the generated
139
+ # readers, including the in-transaction rule. The result is deep-frozen —
140
+ # it shares structure with the process-wide snapshot.
141
+ def scoped_overrides(key)
142
+ definition = registry.fetch(key)
143
+ parsed_scoped_overrides(current_snapshot, definition.key)
144
+ end
145
+
146
+ # Every registered dial's full state — definition (with its JSON Schema),
147
+ # global override (explicitly present-or-absent), scoped overrides, and
148
+ # the per-override stale-write tokens — read from ONE snapshot, so the
149
+ # picture is coherent. Feed an override's token back as
150
+ # `expected_version:` when writing it (Dials::ABSENT_VERSION for
151
+ # overrides the page showed as not stored).
152
+ def overview
153
+ snapshot = current_snapshot
154
+ dials = registry.map do |definition|
155
+ stamps = snapshot.row_versions[definition.key] || {}
156
+ DialState.new(
157
+ definition: definition,
158
+ global_override: snapshot.globals.key?(definition.key),
159
+ global_value: snapshot.globals[definition.key],
160
+ global_version: StoreVersion.token(stamps[Scope::GLOBAL] || 0),
161
+ scoped_overrides: parsed_scoped_overrides(snapshot, definition.key),
162
+ scoped_override_versions: parsed_versions(snapshot, definition.key)
163
+ )
164
+ end.freeze
165
+ Overview.new(version: StoreVersion.token(snapshot.version), dials: dials)
166
+ end
167
+
168
+ # The full change log, newest first. `key:` filters to one dial.
169
+ def changes(key: nil, limit: 50)
170
+ key = registry.fetch(key).key if key
171
+ store.changes(key: key, limit: limit)
172
+ end
173
+
174
+ # -- writes --------------------------------------------------------------
175
+
176
+ # Store an override by key — the primitive under the generated
177
+ # adjust_<key> methods. With no scope, overrides the global; with a
178
+ # scope, creates or updates the override for exactly that scope. The
179
+ # value is validated against the dial's type and schema; `actor:` is
180
+ # required and lands in the change log.
181
+ #
182
+ # `expected_version:` makes the write compare-and-swap against THIS
183
+ # override (the global when no scope keywords, the named scoped override
184
+ # otherwise): pass the override's token from Dials.overview (or a
185
+ # previous CAS write; Dials::ABSENT_VERSION when the page showed no
186
+ # override) and the write is refused with StaleWrite — unapplied,
187
+ # unlogged — if that override has changed since. A CAS write returns the
188
+ # override's NEW token (chain it into the next write); an unconditional
189
+ # write returns the value, as always.
190
+ def set(key, value, actor:, scope: nil, expected_version: nil)
191
+ definition = registry.fetch(key)
192
+ actor_attrs = Actor.normalize(actor)
193
+ definition.validate_value!(value)
194
+
195
+ if scope.nil? || scope.empty?
196
+ canonical = Scope::GLOBAL
197
+ else
198
+ raise InvalidScope, "dial #{definition.key} declares no dimensions" unless definition.dimensions?
199
+
200
+ normalized = Scope.validate!(definition, scope, exact: true)
201
+ canonical = Scope.canonical(normalized)
202
+ end
203
+ _old, written = store.set_override(definition.key, canonical, value, actor_attrs,
204
+ expected_version: expected_version)
205
+
206
+ after_write
207
+ # The token comes from the write we KNOW happened — never from a
208
+ # second read a concurrent writer could slip in front of.
209
+ expected_version ? StoreVersion.token(written) : value
210
+ end
211
+
212
+ # Remove an override by key — the primitive under the generated
213
+ # clear_<key> methods — returning resolution to the next layer down: a
214
+ # cleared scoped override inherits the global; a cleared global inherits the
215
+ # code default. Returns true if an override existed. Clearing what is not
216
+ # there is a no-op (and logs nothing).
217
+ #
218
+ # `expected_version:` works exactly as on set — the staleness check runs
219
+ # even when the clear would be a no-op (a page that shows an override
220
+ # which no longer exists IS stale), and a CAS clear returns the
221
+ # tombstone's token instead of the boolean (chainable: a later set
222
+ # carrying it succeeds; an "absent" assertion from an older page does
223
+ # not — cleared is not the same as never-written).
224
+ def clear(key, actor:, scope: nil, expected_version: nil)
225
+ definition = registry.fetch(key)
226
+ actor_attrs = Actor.normalize(actor)
227
+
228
+ if scope.nil? || scope.empty?
229
+ canonical = Scope::GLOBAL
230
+ else
231
+ normalized = Scope.validate!(definition, scope, exact: true)
232
+ canonical = Scope.canonical(normalized)
233
+ end
234
+ removed, written = store.clear_override(definition.key, canonical, actor_attrs,
235
+ expected_version: expected_version)
236
+
237
+ after_write
238
+ expected_version ? StoreVersion.token(written) : removed
239
+ end
240
+
241
+ private
242
+
243
+ # { canonical scope string => value } from the snapshot, re-keyed by
244
+ # parsed scope hash. Values are already frozen snapshot references; the
245
+ # freshly built hashes are frozen so no caller can mutate shared state.
246
+ def parsed_scoped_overrides(snapshot, key)
247
+ stored = snapshot.scoped_overrides[key] || {}
248
+ stored.to_h { |canonical, value| [Freeze.deep(Scope.parse(canonical)), value] }.freeze
249
+ end
250
+
251
+ # { parsed scope hash => version token } for a dial's scoped overrides.
252
+ def parsed_versions(snapshot, key)
253
+ stamps = snapshot.row_versions[key] || {}
254
+ stamps.except(Scope::GLOBAL)
255
+ .to_h { |canonical, stamp| [Freeze.deep(Scope.parse(canonical)), StoreVersion.token(stamp)] }.freeze
256
+ end
257
+
258
+ def after_write
259
+ cache.bust!
260
+ return unless store_transaction_open?
261
+
262
+ # The write is inside an application transaction and not committed
263
+ # yet. Two things follow. This thread's reads must bypass the shared
264
+ # cache until the transaction closes (see current_snapshot). And the
265
+ # bust above happened PRE-commit — another thread can legitimately
266
+ # republish the pre-transaction state before the commit lands — so the
267
+ # cache must be busted again ON commit, or a writer that never reads
268
+ # again would leave every process serving the old value until the TTL
269
+ # probe notices (forever, with ttl = nil). On rollback the hook is
270
+ # discarded: the shared cache never held the transaction's data.
271
+ Thread.current[TXN_WRITE_KEY] = true
272
+ store.after_commit { cache.bust! } if store.respond_to?(:after_commit)
273
+ end
274
+
275
+ def current_snapshot
276
+ if Thread.current[TXN_WRITE_KEY]
277
+ return cache.uncached_snapshot if store_transaction_open?
278
+
279
+ # The transaction closed (committed or rolled back). Rejoin the
280
+ # shared cache, busting first so the next snapshot reflects the
281
+ # outcome rather than anything published mid-transaction.
282
+ Thread.current[TXN_WRITE_KEY] = nil
283
+ cache.bust!
284
+ end
285
+
286
+ cache.snapshot
287
+ end
288
+
289
+ def store_transaction_open?
290
+ s = store
291
+ s.respond_to?(:transaction_open?) && s.transaction_open?
292
+ end
293
+ end
294
+
295
+ @registry = Registry.new
296
+ @config = Config.new
297
+ end
298
+
299
+ begin
300
+ require "rails/railtie"
301
+ require_relative "dials/railtie"
302
+ rescue LoadError
303
+ nil
304
+ rescue StandardError => e
305
+ # A broken or incompatible Rails installation must not stop the core gem
306
+ # from loading — Rails integration is opportunistic, never required.
307
+ warn "[dials] skipping Rails integration (#{e.class}: #{e.message})"
308
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Dials
7
+ module Generators
8
+ # `bin/rails generate dials:install`
9
+ #
10
+ # Creates the migration for the three gem-owned tables and an initializer
11
+ # with a commented starter registry.
12
+ class InstallGenerator < Rails::Generators::Base
13
+ include ::ActiveRecord::Generators::Migration
14
+
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ def create_migration_file
18
+ migration_template "migration.rb.tt", "db/migrate/create_dials_tables.rb"
19
+ end
20
+
21
+ def create_initializer
22
+ template "initializer.rb.tt", "config/initializers/dials.rb"
23
+ end
24
+
25
+ def show_readme
26
+ say <<~TEXT
27
+
28
+ Dials installed. Next steps:
29
+
30
+ 1. bin/rails db:migrate
31
+ 2. Declare your dials in config/initializers/dials.rb
32
+ 3. Declaring dial :base_fee generates Dials.base_fee,
33
+ Dials.adjust_base_fee, and Dials.clear_base_fee
34
+
35
+ TEXT
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dials/active_record"
4
+
5
+ Dials.configure do |config|
6
+ # Where values live. :active_record uses the tables created by
7
+ # `rails g dials:install`; :memory keeps everything in-process (handy in
8
+ # test environments that never exercise persistence).
9
+ config.store = :active_record
10
+
11
+ # Seconds between cache staleness probes (one cheap query per process per
12
+ # interval). 0 probes on every read; nil never probes.
13
+ # config.cache_ttl = 5.0
14
+
15
+ # How an actor is rendered in the change log.
16
+ # config.actor_label = ->(actor) { actor.email }
17
+
18
+ # No User model? Attribution does not need one — actor: takes any object,
19
+ # and strings are first-class (actor: "console"). To make actor: optional,
20
+ # declare an app-level fallback (a value, or a callable evaluated per
21
+ # write). Leave unset to require actor: on every write.
22
+ # config.default_actor = -> { ENV.fetch("USER", "console") }
23
+ end
24
+
25
+ # Declare your dials. A dial with no `dimensions:` is global-only by
26
+ # construction. Declaring `dimensions:` is the arming gate — add it in the same
27
+ # change as the code that reads the varied value.
28
+ #
29
+ # Value constraints are JSON Schema keywords, snake_cased (minimum:,
30
+ # maximum:, enum:, pattern:, min_length:, properties:/required: for :json) —
31
+ # the vocabulary you already know from JSON Schema and OpenAPI. `validate:`
32
+ # takes a callable for rules a schema cannot express.
33
+ #
34
+ # Each declaration generates the dial's methods, e.g. for :merchant_fee_bps:
35
+ # Dials.merchant_fee_bps(market: "KE")
36
+ # Dials.adjust_merchant_fee_bps(90, actor: current_admin, market: "KE")
37
+ # Dials.clear_merchant_fee_bps(actor: current_admin, market: "KE")
38
+ Dials.define do
39
+ # dial :merchant_fee_bps, default: 100,
40
+ # type: :integer,
41
+ # minimum: 1,
42
+ # maximum: 10_000,
43
+ # unit: "bps",
44
+ # description: "Fee charged to merchants, in basis points.",
45
+ # dimensions: { market: { enum: %w[KE NG BD] } }
46
+
47
+ # dial :signups_enabled, default: true, type: :boolean,
48
+ # description: "Global kill switch for new signups."
49
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDialsTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ # The gem's ONE table, append-only: every write INSERTs a row, and the
6
+ # newest row per (key, scope) stream is the current override — action
7
+ # "set" carries a value, action "clear" ends the override. The change
8
+ # log IS the state (history cannot disagree with it), the row count is
9
+ # the cache's version counter, and UNIQUE(key, scope, seq) makes writes
10
+ # atomic: every writer claims the stream's next seq, and of two
11
+ # concurrent claims the database rejects one — no lock table, no
12
+ # advisory locks, no race.
13
+ #
14
+ # A global override is the stream at the canonical empty scope "{}".
15
+ # `value` is JSON-encoded text, never queried in SQL — reads go through
16
+ # the in-process cache. No updated_at: rows are immutable facts.
17
+ #
18
+ # Explicit column limits keep the composite unique index inside every
19
+ # supported database's index budget (MySQL utf8mb4 in particular).
20
+ # On MySQL, the default case-insensitive collation would merge streams
21
+ # whose scopes differ only by case ("KE" vs "ke") — identity columns get
22
+ # a binary collation there. PostgreSQL and SQLite compare bytes already.
23
+ identity_collation = ("utf8mb4_bin" if connection.adapter_name.match?(/mysql/i))
24
+
25
+ create_table :dials do |t|
26
+ t.string :key, null: false, limit: 100, collation: identity_collation
27
+ t.string :scope, null: false, limit: 255, collation: identity_collation
28
+ t.bigint :seq, null: false
29
+ t.string :action, null: false
30
+ t.text :value
31
+ t.string :actor_type
32
+ t.string :actor_id
33
+ t.string :actor_label
34
+ t.datetime :created_at, null: false
35
+ end
36
+ add_index :dials, [:key, :scope, :seq], unique: true
37
+ add_index :dials, :key
38
+ end
39
+ end