dials 0.3.0 → 0.4.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,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # A full dials instance: its own registry, config, store, table, cache,
5
+ # change log, generated readers and test overrides. A subsystem that owns
6
+ # a namespace owns its operator settings end to end — nothing it declares
7
+ # or writes touches the host app's dials, and nothing the host writes
8
+ # touches its own.
9
+ #
10
+ # The root namespace (name :default) is the one the `Dials` module itself
11
+ # delegates to, so an app that never mentions namespaces uses exactly one.
12
+ # Every other namespace is created with Dials.namespace:
13
+ #
14
+ # Shipping = Dials.namespace(:shipping, label: "Shipping") do |config|
15
+ # config.store = :active_record # table: "shipping_dials"
16
+ # end
17
+ #
18
+ # Shipping.define { dial :max_parcel_kg, default: 20, type: :integer }
19
+ # Shipping.max_parcel_kg # => 20
20
+ #
21
+ # A dial resolves inside its namespace only: there is no cross-namespace
22
+ # fallback, and the same key may be declared in two namespaces.
23
+ class Namespace
24
+ ROOT_NAME = :default
25
+
26
+ # A name becomes a table name and (for an ActiveRecord store) a model
27
+ # class name. Every segment starts with a letter, so camelizing the
28
+ # segments is reversible and two names can never derive one model class:
29
+ # "flat_rate" -> FlatRateEntry, and nothing else does.
30
+ NAME_FORMAT = /\A[a-z][a-z0-9]*(_[a-z][a-z0-9]*)*\z/
31
+
32
+ attr_reader :name, :registry, :config, :txn_write_key
33
+
34
+ # The module holding this namespace's generated per-dial methods.
35
+ attr_reader :generated_module
36
+
37
+ def initialize(name, label: nil, parent: nil)
38
+ @name = name.to_sym
39
+ unless NAME_FORMAT.match?(@name.to_s)
40
+ raise InvalidNamespace, "#{name.inspect}: a namespace name must be lowercase segments of " \
41
+ "letters and digits, each starting with a letter, joined by single " \
42
+ "underscores (it becomes a table name and a model class name)"
43
+ end
44
+
45
+ @parent = parent
46
+ @children = []
47
+ @generated_module = Module.new
48
+ @cache_lock = Mutex.new
49
+ @cache = nil
50
+ # Per namespace, so a write in one namespace never changes how another
51
+ # reads: the marker is keyed by name, not shared.
52
+ @txn_write_key = :"dials_wrote_in_open_transaction_#{@name}"
53
+
54
+ @registry = Registry.new(self)
55
+ @storage = Storage.new(self, parent: parent&.storage)
56
+ @config = Config.new(self, @storage, parent: parent&.config)
57
+ @config.label = label if label
58
+ extend @generated_module
59
+
60
+ parent&.adopt(self)
61
+ end
62
+
63
+ def root?
64
+ @name == ROOT_NAME
65
+ end
66
+
67
+ def label
68
+ config.label
69
+ end
70
+
71
+ def default_label
72
+ root? ? "Dials" : @name.to_s.tr("_", " ").capitalize
73
+ end
74
+
75
+ def inspect
76
+ "#<Dials::Namespace #{@name} #{registry.keys.length} dials>"
77
+ end
78
+
79
+ # -- declaration ---------------------------------------------------------
80
+
81
+ # Declare dials in this namespace:
82
+ #
83
+ # ns.define do
84
+ # dial :merchant_fee_bps, default: 100, type: :integer,
85
+ # minimum: 1, maximum: 10_000, unit: "bps",
86
+ # dimensions: { market: { enum: %w[KE NG BD] } }
87
+ # end
88
+ #
89
+ # Each declaration generates the dial's methods on this namespace:
90
+ # merchant_fee_bps (the reader), adjust_merchant_fee_bps,
91
+ # clear_merchant_fee_bps (see Generated).
92
+ def define(&)
93
+ registry.instance_eval(&)
94
+ end
95
+
96
+ # Internal, called by the registry; see Generated.install!.
97
+ def install_generated!(definition)
98
+ Generated.install!(definition, into: @generated_module, owners: collision_owners)
99
+ end
100
+
101
+ def uninstall_generated!
102
+ Generated.uninstall_all!(from: @generated_module)
103
+ end
104
+
105
+ # -- configuration -------------------------------------------------------
106
+
107
+ def configure
108
+ yield config
109
+ end
110
+
111
+ def store
112
+ config.store
113
+ end
114
+
115
+ def cache
116
+ @cache || @cache_lock.synchronize { @cache ||= Cache.new(store: store, ttl: config.cache_ttl) }
117
+ end
118
+
119
+ # Discard the cache object entirely (used when the store is swapped).
120
+ def reset_cache!
121
+ @cache_lock.synchronize { @cache = nil }
122
+ end
123
+
124
+ # Force the next read to rebuild from the store — e.g. after writing
125
+ # through a console in another process, or in a test. Also clears this
126
+ # thread's in-transaction-write marker (test suites that wrap examples
127
+ # in transactions call this between examples).
128
+ def reload!
129
+ Thread.current[@txn_write_key] = nil
130
+ cache.bust!
131
+ end
132
+
133
+ # Test hook: forget every option this namespace was configured with,
134
+ # and the store built from them, so an example starts from the shipped
135
+ # defaults.
136
+ def reset_config!
137
+ @storage = Storage.new(self, parent: @parent&.storage)
138
+ @config = Config.new(self, @storage, parent: @parent&.config)
139
+ reset_cache!
140
+ end
141
+
142
+ # Test hook for Dials.reset_namespaces!: a discarded namespace stops
143
+ # inheriting config changes.
144
+ def forget_children!
145
+ @children.clear
146
+ end
147
+
148
+ # Internal: a cache_ttl change reaches the namespace's own cache, and
149
+ # every namespace that inherits the value rather than declaring one.
150
+ def apply_cache_ttl
151
+ @cache&.ttl = config.cache_ttl
152
+ @children.each(&:inherit_cache_ttl)
153
+ end
154
+
155
+ # Internal: same for a store swap. A namespace that inherits the kind
156
+ # discards the store it built from the OLD kind — an app that configures
157
+ # Dials after an engine declared its namespace must not leave that
158
+ # engine on the default memory store.
159
+ def apply_store
160
+ reset_cache!
161
+ @children.each(&:inherit_store)
162
+ end
163
+
164
+ # -- reads ---------------------------------------------------------------
165
+
166
+ # Resolve a dial by key — the primitive under the generated readers,
167
+ # for callers that receive the key at runtime. Scope is passed
168
+ # as keyword arguments and must name every dimension the dial declares —
169
+ # no more, no less:
170
+ #
171
+ # ns.get(:signups_enabled) # global-only dial
172
+ # ns.get(:merchant_fee_bps, market: "KE") # varied dial
173
+ #
174
+ # Raises UnknownDial / InvalidScope on misuse; never raises for a merely
175
+ # missing override (that is what defaults are for).
176
+ def get(key, **scope)
177
+ definition = registry.fetch(key)
178
+ normalized = Scope.validate!(definition, scope, exact: true)
179
+
180
+ # After scope validation, so a test override can never mask a read that
181
+ # would raise in production.
182
+ pinned = Testing.override_for(self, definition.key)
183
+ return pinned.first if pinned
184
+
185
+ Resolver.resolve(definition, normalized, current_snapshot)
186
+ end
187
+
188
+ # Read a dial's Global layer by key: the stored global override when
189
+ # present, else the code default — the tail every un-overridden scope
190
+ # falls through to. This is the front door for the caller that has NO
191
+ # scope to give — resolving a value for a subject whose dimension is
192
+ # unknowable (a recipient with no resolvable market) — not a way around
193
+ # exact-scope reads: a caller that knows its scope must still pass it
194
+ # to get, which raises InvalidScope precisely so a lazy read cannot
195
+ # skip a scoped override. For a dial with no dimensions this is
196
+ # equivalent to get. Raises UnknownDial; honors Testing pins.
197
+ def global(key)
198
+ definition = registry.fetch(key)
199
+
200
+ pinned = Testing.override_for(self, definition.key)
201
+ return pinned.first if pinned
202
+
203
+ # The empty scope matches no stored scoped override, so Resolver
204
+ # takes exactly the global-override → code-default tail.
205
+ Resolver.resolve(definition, {}, current_snapshot)
206
+ end
207
+
208
+ # One dial's stored scoped overrides as { parsed scope => value }, e.g.
209
+ # { { market: "BD" } => 24, { market: "NG" } => 48 } — "which markets
210
+ # override this dial?". Scopes come back as parsed hashes, never
211
+ # canonical scope strings. A dial with nothing scoped stored (or no
212
+ # dimensions at all) returns {}; the global override is not included
213
+ # (see overview). Reads from the same snapshot path as the generated
214
+ # readers, including the in-transaction rule. The result is deep-frozen —
215
+ # it shares structure with the process-wide snapshot.
216
+ def scoped_overrides(key)
217
+ definition = registry.fetch(key)
218
+ parsed_scoped_overrides(current_snapshot, definition.key)
219
+ end
220
+
221
+ # Every dial registered in this namespace, with its full state —
222
+ # definition (with its JSON Schema), global override (explicitly
223
+ # present-or-absent), scoped overrides, and the per-override stale-write
224
+ # tokens — read from ONE snapshot, so the picture is coherent. Feed an
225
+ # override's token back as `expected_version:` when writing it
226
+ # (Dials::ABSENT_VERSION for overrides the page showed as not stored).
227
+ def overview
228
+ snapshot = current_snapshot
229
+ dials = registry.map do |definition|
230
+ stamps = snapshot.row_versions[definition.key] || {}
231
+ DialState.new(
232
+ definition: definition,
233
+ global_override: snapshot.globals.key?(definition.key),
234
+ global_value: snapshot.globals[definition.key],
235
+ global_version: StoreVersion.token(stamps[Scope::GLOBAL] || 0),
236
+ scoped_overrides: parsed_scoped_overrides(snapshot, definition.key),
237
+ scoped_override_versions: parsed_versions(snapshot, definition.key)
238
+ )
239
+ end.freeze
240
+ Overview.new(version: StoreVersion.token(snapshot.version), dials: dials)
241
+ end
242
+
243
+ # This namespace's change log, newest first. `key:` filters to one dial.
244
+ def changes(key: nil, limit: 50)
245
+ key = registry.fetch(key).key if key
246
+ store.changes(key: key, limit: limit)
247
+ end
248
+
249
+ # -- writes --------------------------------------------------------------
250
+
251
+ # Store an override by key — the primitive under the generated
252
+ # adjust_<key> methods. With no scope, overrides the global; with a
253
+ # scope, creates or updates the override for exactly that scope. The
254
+ # value is validated against the dial's type and schema; `actor:` is
255
+ # required and lands in the change log.
256
+ #
257
+ # `expected_version:` makes the write compare-and-swap against THIS
258
+ # override (the global when no scope keywords, the named scoped override
259
+ # otherwise): pass the override's token from ns.overview (or a
260
+ # previous CAS write; Dials::ABSENT_VERSION when the page showed no
261
+ # override) and the write is refused with StaleWrite — unapplied,
262
+ # unlogged — if that override has changed since. A CAS write returns the
263
+ # override's NEW token (chain it into the next write); an unconditional
264
+ # write returns the value, as always.
265
+ def set(key, value, actor:, scope: nil, expected_version: nil)
266
+ definition = registry.fetch(key)
267
+ actor_attrs = Actor.normalize(actor, config)
268
+ definition.validate_value!(value)
269
+
270
+ if scope.nil? || scope.empty?
271
+ canonical = Scope::GLOBAL
272
+ else
273
+ raise InvalidScope, "dial #{definition.key} declares no dimensions" unless definition.dimensions?
274
+
275
+ normalized = Scope.validate!(definition, scope, exact: true)
276
+ canonical = Scope.canonical(normalized)
277
+ end
278
+ _old, written = store.set_override(definition.key, canonical, value, actor_attrs,
279
+ expected_version: expected_version)
280
+
281
+ after_write
282
+ # The token comes from the write we KNOW happened — never from a
283
+ # second read a concurrent writer could slip in front of.
284
+ expected_version ? StoreVersion.token(written) : value
285
+ end
286
+
287
+ # Remove an override by key — the primitive under the generated
288
+ # clear_<key> methods — returning resolution to the next layer down: a
289
+ # cleared scoped override inherits the global; a cleared global inherits the
290
+ # code default. Returns true if an override existed. Clearing what is not
291
+ # there is a no-op (and logs nothing).
292
+ #
293
+ # `expected_version:` works exactly as on set — the staleness check runs
294
+ # even when the clear would be a no-op (a page that shows an override
295
+ # which no longer exists IS stale), and a CAS clear returns the
296
+ # tombstone's token instead of the boolean (chainable: a later set
297
+ # carrying it succeeds; an "absent" assertion from an older page does
298
+ # not — cleared is not the same as never-written).
299
+ def clear(key, actor:, scope: nil, expected_version: nil)
300
+ definition = registry.fetch(key)
301
+ actor_attrs = Actor.normalize(actor, config)
302
+
303
+ if scope.nil? || scope.empty?
304
+ canonical = Scope::GLOBAL
305
+ else
306
+ normalized = Scope.validate!(definition, scope, exact: true)
307
+ canonical = Scope.canonical(normalized)
308
+ end
309
+ removed, written = store.clear_override(definition.key, canonical, actor_attrs,
310
+ expected_version: expected_version)
311
+
312
+ after_write
313
+ expected_version ? StoreVersion.token(written) : removed
314
+ end
315
+
316
+ # -- test overrides ------------------------------------------------------
317
+
318
+ # Pin this namespace's dial values for the duration of a block, without
319
+ # touching the store, the cache, or the change log — see Testing.
320
+ def with_overrides(overrides, &)
321
+ Testing.with_overrides(overrides, self, &)
322
+ end
323
+
324
+ def adopt(child)
325
+ @children << child
326
+ end
327
+
328
+ protected attr_reader :storage
329
+
330
+ # Internal: this namespace reads its parent's cache_ttl, so a change
331
+ # there reaches a cache that was already built.
332
+ def inherit_cache_ttl
333
+ return if config.explicitly_set?(:cache_ttl)
334
+
335
+ @cache&.ttl = config.cache_ttl
336
+ end
337
+
338
+ def inherit_store
339
+ return if @storage.declared?
340
+
341
+ @storage.discard_store!
342
+ reset_cache!
343
+ end
344
+
345
+ private
346
+
347
+ def collision_owners
348
+ root? ? { "Dials" => Dials, "Dials.default" => self } : { "Dials.namespace(:#{@name})" => self }
349
+ end
350
+
351
+ # { canonical scope string => value } from the snapshot, re-keyed by
352
+ # parsed scope hash. Values are already frozen snapshot references; the
353
+ # freshly built hashes are frozen so no caller can mutate shared state.
354
+ def parsed_scoped_overrides(snapshot, key)
355
+ stored = snapshot.scoped_overrides[key] || {}
356
+ stored.to_h { |canonical, value| [Freeze.deep(Scope.parse(canonical)), value] }.freeze
357
+ end
358
+
359
+ # { parsed scope hash => version token } for a dial's scoped overrides.
360
+ def parsed_versions(snapshot, key)
361
+ stamps = snapshot.row_versions[key] || {}
362
+ stamps.except(Scope::GLOBAL)
363
+ .to_h { |canonical, stamp| [Freeze.deep(Scope.parse(canonical)), StoreVersion.token(stamp)] }.freeze
364
+ end
365
+
366
+ def after_write
367
+ cache.bust!
368
+ return unless store_transaction_open?
369
+
370
+ # The write is inside an application transaction and not committed
371
+ # yet. Two things follow. This thread's reads must bypass the shared
372
+ # cache until the transaction closes (see current_snapshot). And the
373
+ # bust above happened PRE-commit — another thread can legitimately
374
+ # republish the pre-transaction state before the commit lands — so the
375
+ # cache must be busted again ON commit, or a writer that never reads
376
+ # again would leave every process serving the old value until the TTL
377
+ # probe notices (forever, with ttl = nil). On rollback the hook is
378
+ # discarded: the shared cache never held the transaction's data.
379
+ Thread.current[@txn_write_key] = true
380
+ store.after_commit { cache.bust! } if store.respond_to?(:after_commit)
381
+ end
382
+
383
+ def current_snapshot
384
+ if Thread.current[@txn_write_key]
385
+ return cache.uncached_snapshot if store_transaction_open?
386
+
387
+ # The transaction closed (committed or rolled back). Rejoin the
388
+ # shared cache, busting first so the next snapshot reflects the
389
+ # outcome rather than anything published mid-transaction.
390
+ Thread.current[@txn_write_key] = nil
391
+ cache.bust!
392
+ end
393
+
394
+ cache.snapshot
395
+ end
396
+
397
+ def store_transaction_open?
398
+ s = store
399
+ s.respond_to?(:transaction_open?) && s.transaction_open?
400
+ end
401
+ end
402
+ end
@@ -1,33 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dials
4
- # The in-code catalog of every dial the application declares. A key's
5
- # presence here is what makes it a dial at all: reads, writes, and scope
6
- # validation all consult the registry, and an admin surface renders exactly
7
- # these entries.
4
+ # The in-code catalog of every dial a namespace declares. A key's presence
5
+ # here is what makes it a dial at all: reads, writes, and scope validation
6
+ # all consult the registry, and an admin surface renders exactly these
7
+ # entries.
8
8
  #
9
9
  # Declarations accumulate across `Dials.define` blocks (so large apps can
10
10
  # split declarations by domain), but a key declared twice raises — a dial's
11
- # declaration is its single source of truth.
11
+ # declaration is its single source of truth. A key is unique inside its
12
+ # namespace only: two namespaces may each declare :timeout_seconds.
12
13
  class Registry
13
14
  include Enumerable
14
15
 
15
- def initialize
16
+ def initialize(namespace)
17
+ @namespace = namespace
16
18
  @definitions = {}
17
19
  @mutex = Mutex.new
18
20
  end
19
21
 
20
22
  # DSL entry point used by `Dials.define { dial ... }`. Registering a key
21
- # also generates its per-dial methods (the Dials.<key> reader and the
22
- # adjust_/clear_ writers);
23
- # Generated.install! checks for name collisions before defining anything,
24
- # so a raise here leaves neither a definition nor a stray method behind.
23
+ # also generates its per-dial methods on the namespace (the reader and
24
+ # the adjust_/clear_ writers); the namespace checks for name collisions
25
+ # before defining anything, so a raise here leaves neither a definition
26
+ # nor a stray method behind.
25
27
  def dial(key, **)
26
28
  definition = Definition.new(key, **)
27
29
  @mutex.synchronize do
28
30
  raise DuplicateDial, "dial #{definition.key} is already defined" if @definitions.key?(definition.key)
29
31
 
30
- Generated.install!(definition)
32
+ @namespace.install_generated!(definition)
31
33
  @definitions[definition.key] = definition
32
34
  end
33
35
  definition
@@ -61,7 +63,7 @@ module Dials
61
63
  def reset!
62
64
  @mutex.synchronize do
63
65
  @definitions.clear
64
- Generated.uninstall_all!
66
+ @namespace.uninstall_generated!
65
67
  end
66
68
  end
67
69
  end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # Where one namespace's values live: the kind of store, the table the
5
+ # namespace owns, and the store object built from the two.
6
+ #
7
+ # A namespace owns its rows, so a namespace that declares no kind inherits
8
+ # the root's KIND and gets a table of its own. A store OBJECT is never
9
+ # inherited: two namespaces on one store would share its key space, so a
10
+ # namespace under a store object must name its own.
11
+ class Storage
12
+ # The root's table, and the suffix every other namespace's table carries.
13
+ DEFAULT_TABLE_NAME = "dials"
14
+
15
+ # A table name reaches the model's table_name and the correlated
16
+ # NOT EXISTS subquery the store builds by hand, so it must be a plain
17
+ # unquoted identifier: no reserved-word surprises, no schema
18
+ # qualification, no case folding.
19
+ TABLE_NAME_FORMAT = /\A[a-z][a-z0-9_]*\z/
20
+
21
+ # PostgreSQL truncates identifiers past 63 bytes, which would silently
22
+ # merge two tables whose names differ only past the cut.
23
+ MAX_TABLE_NAME_LENGTH = 63
24
+
25
+ attr_reader :table_name_prefix
26
+
27
+ def initialize(namespace, parent: nil)
28
+ @namespace = namespace
29
+ @parent = parent
30
+ @kind = nil
31
+ @declared = false
32
+ @store = nil
33
+ @table_name = nil
34
+ @table_name_prefix = nil
35
+ validate_table_name!(table_name)
36
+ end
37
+
38
+ # A store instance, or the symbols :memory / :active_record.
39
+ def kind=(kind)
40
+ @declared = true
41
+ @kind = kind
42
+ @store = build_store(kind)
43
+ end
44
+
45
+ # False when this storage reads the root's kind rather than naming one.
46
+ def declared?
47
+ @declared
48
+ end
49
+
50
+ def store
51
+ @store ||= build_store(kind)
52
+ end
53
+
54
+ # Drop the built store: the next read builds one from the current kind
55
+ # and table name.
56
+ def discard_store!
57
+ @store = nil
58
+ end
59
+
60
+ # The prefixed "dials" for the root, "<name>_dials" for every other
61
+ # namespace, or whatever table_name= says.
62
+ def table_name
63
+ resolve_table_name(@table_name, @table_name_prefix)
64
+ end
65
+
66
+ # Both name setters are order-independent with kind=: whichever runs
67
+ # second applies the name, and nil on either restores the derived one.
68
+ # Each claims the name it would produce BEFORE taking it, so a rejected
69
+ # setter leaves the namespace on the table it already had.
70
+ def table_name=(name)
71
+ name = name&.to_s
72
+ claim_table!(resolve_table_name(name, @table_name_prefix))
73
+ @table_name = name
74
+ rename_table
75
+ end
76
+
77
+ def table_name_prefix=(prefix)
78
+ claim_table!(resolve_table_name(@table_name, prefix))
79
+ @table_name_prefix = prefix
80
+ rename_table
81
+ end
82
+
83
+ protected
84
+
85
+ # What a namespace's storage inherits when it declares no kind of its own.
86
+ def kind
87
+ return @kind if @declared
88
+ return :memory unless @parent
89
+
90
+ inherited = @parent.kind
91
+ return inherited if inherited.is_a?(Symbol)
92
+
93
+ raise Error, "namespace #{@namespace.name} cannot inherit a store object " \
94
+ "(two namespaces sharing one store share its rows); set config.store for it"
95
+ end
96
+
97
+ private
98
+
99
+ def resolve_table_name(name, prefix)
100
+ return name if name
101
+ return "#{prefix}#{DEFAULT_TABLE_NAME}" if @namespace.root?
102
+
103
+ "#{@namespace.name}_#{DEFAULT_TABLE_NAME}"
104
+ end
105
+
106
+ # Renaming an already-declared namespace's table: the shape is this
107
+ # object's business, the claim is the module's, because only the module
108
+ # knows every namespace.
109
+ def claim_table!(name)
110
+ validate_table_name!(name)
111
+ Dials.assert_table_unclaimed!(@namespace, name)
112
+ end
113
+
114
+ def validate_table_name!(name)
115
+ return if TABLE_NAME_FORMAT.match?(name) && name.length <= MAX_TABLE_NAME_LENGTH
116
+
117
+ raise InvalidTableName,
118
+ "#{name.inspect}: a dials table name must be lowercase letters, digits and " \
119
+ "underscores, at most #{MAX_TABLE_NAME_LENGTH} characters"
120
+ end
121
+
122
+ def build_store(kind)
123
+ case kind
124
+ when :memory then Stores::Memory.new
125
+ when :active_record
126
+ require "dials/active_record"
127
+ Stores::ActiveRecordStore.new(model: active_record_model)
128
+ else kind
129
+ end
130
+ end
131
+
132
+ # The root keeps the well-known Dials::ActiveRecord::Entry; every other
133
+ # namespace gets a model class of its own, because its rows live in its
134
+ # own table.
135
+ def active_record_model
136
+ model = @namespace.root? ? Dials::ActiveRecord::Entry : Dials::ActiveRecord.model(@namespace.name)
137
+ model.table_name = table_name
138
+ model
139
+ end
140
+
141
+ # Renaming the root's table renames the model other code already holds
142
+ # (Dials::ActiveRecord::Entry, since 0.2.0). Any other namespace has no
143
+ # such published constant, so dropping the store is enough — the next
144
+ # read builds one whose model carries the new name.
145
+ def rename_table
146
+ if @namespace.root?
147
+ Dials::ActiveRecord::Entry.table_name = table_name if defined?(Dials::ActiveRecord::Entry)
148
+ else
149
+ discard_store!
150
+ end
151
+ end
152
+ end
153
+ end
data/lib/dials/testing.rb CHANGED
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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.
4
+ # Test-override mechanics. `with_overrides` pins dial values for the
5
+ # duration of a block without touching the store, the cache, or the change
6
+ # log — reads inside the block (on the same thread) see the pinned value for
7
+ # every scope of that dial. Nesting composes; inner blocks win.
8
8
  #
9
9
  # Dials::Testing.with_overrides(merchant_fee_bps: 250) do
10
10
  # Dials.merchant_fee_bps(market: "KE") # => 250
@@ -12,29 +12,41 @@ module Dials
12
12
  #
13
13
  # Values are validated against the dial's declaration, so a test cannot
14
14
  # pin a value production could never hold.
15
+ #
16
+ # Pins belong to ONE namespace: they are keyed by its name, so pinning the
17
+ # app's :timeout_seconds leaves an engine's dial of the same name resolving
18
+ # normally. `Dials::Testing.with_overrides` pins the default namespace;
19
+ # every namespace also pins through itself (`Shipping.with_overrides`).
15
20
  module Testing
16
- THREAD_KEY = :dials_testing_overrides
17
-
18
21
  module_function
19
22
 
20
- def with_overrides(overrides)
23
+ def with_overrides(overrides, namespace = Dials.default)
21
24
  validated = overrides.to_h do |key, value|
22
- definition = Dials.registry.fetch(key)
25
+ definition = namespace.registry.fetch(key)
23
26
  [definition.key, definition.validate_value!(value)]
24
27
  end
25
28
 
26
- previous = Thread.current[THREAD_KEY]
27
- Thread.current[THREAD_KEY] = (previous || {}).merge(validated)
29
+ thread_key = thread_key_for(namespace)
30
+ previous = Thread.current[thread_key]
31
+ Thread.current[thread_key] = (previous || {}).merge(validated)
28
32
  yield
29
33
  ensure
30
- Thread.current[THREAD_KEY] = previous
34
+ # thread_key is nil when validation raised — nothing was pinned, and
35
+ # restoring must not mask the caller's error.
36
+ Thread.current[thread_key] = previous if thread_key
31
37
  end
32
38
 
33
- def override_for(key)
34
- overrides = Thread.current[THREAD_KEY]
39
+ # One dial's pin, wrapped in an array so a pinned `false` is still a pin;
40
+ # nil when the dial is not pinned. Called on every read.
41
+ def override_for(namespace, key)
42
+ overrides = Thread.current[thread_key_for(namespace)]
35
43
  return nil unless overrides
36
44
 
37
45
  overrides.key?(key) ? [overrides[key]] : nil
38
46
  end
47
+
48
+ def thread_key_for(namespace)
49
+ :"dials_testing_overrides_#{namespace.name}"
50
+ end
39
51
  end
40
52
  end
data/lib/dials/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dials
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end