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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd4dd1c05a2c416e08b2c22d7e338119a402f9b5c6837060525fdd2f3b01f42e
4
- data.tar.gz: 4ad1737c0a22254df563055ebdab250087a6490e05c116944b56207d96950627
3
+ metadata.gz: b86f0661acabf1181381d1b427daa25ab6cfa3a165bfa0df5cc8aef73bb0f3e6
4
+ data.tar.gz: fe4aeaa5c53ec38b2bc23232ef780ebd535992dcc806664e50a064aac8c86356
5
5
  SHA512:
6
- metadata.gz: b350111d9f196d576eea8ca2a077bacfc1913e3d1698492863760ce74d8612f593ec2a94cf4e757b7e1b3ac04e9dd48ea912ac044f2c3d98441d3e076a8a571c
7
- data.tar.gz: 956c518db63ae813c313b8c5cf1d6b1b8adc17b5f5cd3d15c92fc35943a179ca841a5a2969c32d1575f95515b037442fb8a8f665e4de30c8f5de0454f7147db9
6
+ metadata.gz: 518a76d9607ba4c79a867bc4efa30436ed35a83eae76243a46020b9ed0033f2edf46eafbdd6d3e81b0194d8e23e11592fba9b02f3452ba3138bc06be6ad86072
7
+ data.tar.gz: 567beee01c929e743ee9d1a8257e4c8e8cdd071686cf74d269f37513f89f83a42554ec9aae44d6fb62c229f93a632d967b46cd1cfc0520794a319895b7e2aed7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-09-09
4
+
5
+ - **Namespaces.** A subsystem can own its dials:
6
+ `Dials.namespace(:shipping) { |config| config.store = :active_record }`
7
+ returns a dials instance of its own — its own registry, config, store,
8
+ table (`shipping_dials`), cache, change log, generated readers and test
9
+ overrides. A key is unique inside its namespace, so two namespaces may
10
+ declare the same one, and a dial resolves inside its namespace only.
11
+ `Dials.namespaces` lists every namespace (root first) for an admin surface
12
+ that groups dials by subsystem, and `Dials.default` names the root.
13
+ Unset options (`cache_ttl`, `actor_label`, `default_actor`) inherit the
14
+ root's config; `store` inherits by kind, so an inheriting namespace still
15
+ owns its table (a store *object* is not inheritable — sharing one would
16
+ put two namespaces in one key space). `config.table_name` renames a namespace's table;
17
+ `config.table_name_prefix` still names the root's.
18
+ - **Newly reserved dial keys.** A dial's reader must not shadow a method on
19
+ its namespace, and the namespace object carries methods the `Dials` module
20
+ did not: `label`, `with_overrides`, `default_label`, `root?`, `storage`,
21
+ `txn_write_key`, `generated_module`, `install_generated!`,
22
+ `uninstall_generated!`, `apply_cache_ttl`, `apply_store`,
23
+ `inherit_cache_ttl`, `inherit_store`, `adopt`, `forget_children!` and
24
+ `reset_config!` — plus the new module methods `default`, `namespace`,
25
+ `namespaces`, `reload_all!` and `reset_namespaces!`. A dial declared under
26
+ one of those names now raises `InvalidDefinition` at boot instead of at no
27
+ point.
28
+ - A namespace name must be segments of lowercase letters and digits, each
29
+ starting with a letter, joined by single underscores (`InvalidNamespace`
30
+ otherwise): it becomes a table name and a model class name, and that rule
31
+ keeps both one-to-one with the namespace. (`tier_2` is refused because it
32
+ and `tier2` would derive the same model class, and the second namespace
33
+ would silently repoint the first one's table.)
34
+ - **`Dials::InvalidTableName`.** A table name must be lowercase letters,
35
+ digits and underscores, at most 63 characters — a table the namespace owns
36
+ outright, not a schema-qualified or case-sensitive reference, and
37
+ PostgreSQL truncates identifiers past 63 bytes — and no two namespaces may
38
+ resolve to one table. Both are checked at boot, on `config.table_name`,
39
+ `config.table_name_prefix`, and the name a namespace derives from its own.
40
+ - The ActiveRecord store quotes the table name in the one query it writes by
41
+ hand, so a table named for a reserved word (`order`) loads state instead of
42
+ raising a SQL syntax error on the first read.
43
+ - Configuration is boot-time only: declare namespaces and configure `Dials`
44
+ during boot. Reconfiguration concurrent with live traffic is unsupported.
45
+ - **`Dials.reload_all!`** reloads every namespace, and
46
+ **`Dials.reset_namespaces!`** discards all but the root — for test suites.
47
+ - The `Dials` module is now the default namespace: `Dials.define`,
48
+ `Dials.configure`, the generated readers, `Dials::Testing.with_overrides`,
49
+ `Dials.reload!` and `Dials::ActiveRecord::Entry` behave exactly as before.
50
+ Internals moved with the refactor, none of them documented API:
51
+ `Dials::Testing::THREAD_KEY` and `Dials::Testing.override_for` are gone
52
+ (each namespace keeps its own thread-local pins); `Dials::CACHE_LOCK` and
53
+ `Stores::ActiveRecordStore::Entry` are gone (the store now takes
54
+ `model:`); `Dials::ActiveRecord::Entry` subclasses a new abstract
55
+ `Dials::ActiveRecord::Record`, and its `DEFAULT_TABLE_NAME` moved to the
56
+ new `Dials::Storage`, which owns a namespace's store kind, table and
57
+ model; `Dials::Actor.normalize`, `Registry.new` and `Config.new` now take
58
+ the namespace (or its config and storage) they act for.
59
+
3
60
  ## [0.3.0] - 2026-09-07
4
61
 
5
62
  - **`Dials.global(key)`.** Read a dial's Global layer by key: the stored
data/README.md CHANGED
@@ -51,6 +51,21 @@ clearing every override returns you to exactly what the code says. Reads
51
51
  come from a per-process cache with a throttled staleness probe, so a dial
52
52
  read costs a hash lookup, not a query.
53
53
 
54
+ A subsystem that owns its operator settings declares a **namespace**: a
55
+ dials instance with its own registry, table, cache and change log.
56
+
57
+ ```ruby
58
+ Shipping = Dials.namespace(:shipping, label: "Shipping") do |config|
59
+ config.store = :active_record # table: "shipping_dials"
60
+ end
61
+
62
+ Shipping.define { dial :max_parcel_kg, default: 20, type: :integer, minimum: 1 }
63
+ Shipping.max_parcel_kg # => 20
64
+ ```
65
+
66
+ `Dials` is the default namespace, so an app that never mentions namespaces
67
+ has exactly one.
68
+
54
69
  ## Installation
55
70
 
56
71
  ```bash
@@ -2,17 +2,17 @@
2
2
 
3
3
  module Dials
4
4
  module ActiveRecord
5
- # The one gem-owned table. Values are stored as JSON text (not jsonb)
6
- # so the schema is portable across PostgreSQL, MySQL, and SQLite; nothing
5
+ # One namespace's table. Values are stored as JSON text (not jsonb) so
6
+ # the schema is portable across PostgreSQL, MySQL, and SQLite; nothing
7
7
  # ever queries inside a value or a scope — reads go through the
8
8
  # in-process cache, so the database is durable storage, not a query
9
9
  # surface.
10
10
  #
11
- # This model is internal plumbing for Stores::ActiveRecordStore.
12
- # Application code reads and writes through the Dials facade, which is
13
- # where validation, attribution, and cache busting live. Writing to it
14
- # directly bypasses all of that.
15
-
11
+ # These models are internal plumbing for Stores::ActiveRecordStore.
12
+ # Application code reads and writes through a namespace (the Dials
13
+ # facade is the default one), which is where validation, attribution,
14
+ # and cache busting live. Writing to them directly bypasses all of that.
15
+ #
16
16
  # One row per WRITE — the table is append-only, so the change log IS the
17
17
  # state. The newest row per (key, scope) stream is the current override:
18
18
  # action "set" carries the value; action "clear" says the override is
@@ -33,12 +33,8 @@ module Dials
33
33
  # collation additionally treats scopes differing only by case ("KE" vs
34
34
  # "ke") as one stream — don't declare dimension enums that differ only
35
35
  # by case, or give the table a binary collation.
36
- class Entry < ::ActiveRecord::Base
37
- # Prefixable via Dials.configure { |c| c.table_name_prefix = "zar_" }
38
- # for apps where "dials" collides with an existing table (see
39
- # Config#table_name_prefix).
40
- DEFAULT_TABLE_NAME = "dials"
41
- self.table_name = DEFAULT_TABLE_NAME
36
+ class Record < ::ActiveRecord::Base
37
+ self.abstract_class = true
42
38
 
43
39
  validates :key, :scope, :seq, presence: true
44
40
  validates :action, presence: true, inclusion: { in: %w[set clear] }
@@ -51,5 +47,19 @@ module Dials
51
47
  persisted?
52
48
  end
53
49
  end
50
+
51
+ # The default namespace's table; renamed by Config#table_name_prefix.
52
+ class Entry < Record
53
+ self.table_name = Dials::Storage::DEFAULT_TABLE_NAME
54
+ end
55
+
56
+ # The model for a non-default namespace, named after it so a validation
57
+ # error or a query log says which subsystem's table it is.
58
+ def self.model(namespace_name)
59
+ const = "#{namespace_name.to_s.split('_').map(&:capitalize).join}Entry"
60
+ return const_get(const, false) if const_defined?(const, false)
61
+
62
+ const_set(const, Class.new(Record))
63
+ end
54
64
  end
55
65
  end
@@ -2,12 +2,12 @@
2
2
 
3
3
  module Dials
4
4
  module Stores
5
- # The production store: ONE ActiveRecord-backed, append-only table (see
6
- # Dials::ActiveRecord::Entry). Every write INSERTs a row; the newest row
7
- # per (key, scope) stream is the current override, and the same rows are
8
- # the attributed history and the cache's version counter. The global
9
- # override is the stream at Scope::GLOBAL (the canonical empty scope).
10
- # Implements the same interface as Stores::Memory.
5
+ # The production store: ONE ActiveRecord-backed, append-only table per
6
+ # namespace (see Dials::ActiveRecord::Record). Every write INSERTs a row;
7
+ # the newest row per (key, scope) stream is the current override, and the
8
+ # same rows are the attributed history and the cache's version counter.
9
+ # The global override is the stream at Scope::GLOBAL (the canonical empty
10
+ # scope). Implements the same interface as Stores::Memory.
11
11
  #
12
12
  # Concurrency control is the stream sequence: each row claims its
13
13
  # stream's next `seq` under UNIQUE(key, scope, seq), so of two concurrent
@@ -18,8 +18,6 @@ module Dials
18
18
  # only grows, so a stale-write token (the live row's seq) can never be
19
19
  # revisited by a later delete-and-recreate.
20
20
  class ActiveRecordStore
21
- Entry = Dials::ActiveRecord::Entry
22
-
23
21
  # Sentinel for "this row could not be decoded; skip it".
24
22
  SKIP = Object.new
25
23
 
@@ -39,6 +37,13 @@ module Dials
39
37
  # Operator write rates make even the second attempt rare.
40
38
  WRITE_ATTEMPTS = 3
41
39
 
40
+ # The namespace's model; its table is the namespace's table.
41
+ def initialize(model: Dials::ActiveRecord::Entry)
42
+ @model = model
43
+ end
44
+
45
+ attr_reader :model
46
+
42
47
  def state
43
48
  # Version first: if a write lands between these reads, the snapshot
44
49
  # carries an older version than its data, and the next probe sees the
@@ -64,11 +69,11 @@ module Dials
64
69
  when "set"
65
70
  nil # fall through to the value path
66
71
  else
67
- quarantine("dials(#{row.key}, #{row.scope})", "unknown action #{row.action.inspect}")
72
+ quarantine("#{@model.table_name}(#{row.key}, #{row.scope})", "unknown action #{row.action.inspect}")
68
73
  next
69
74
  end
70
75
 
71
- value = decode_row(row.value, "dials(#{row.key}, #{row.scope})")
76
+ value = decode_row(row.value, "#{@model.table_name}(#{row.key}, #{row.scope})")
72
77
  next if value.equal?(SKIP)
73
78
 
74
79
  if row.scope == Scope::GLOBAL
@@ -93,7 +98,7 @@ module Dials
93
98
  # unrelated write. Count catches it (N → N+1). Both aggregates come
94
99
  # from ONE statement so they describe one committed state, never two.
95
100
  def version
96
- count, max = Entry.pick(Arel.sql("COUNT(*)"), Arel.sql("COALESCE(MAX(id), 0)"))
101
+ count, max = @model.pick(Arel.sql("COUNT(*)"), Arel.sql("COALESCE(MAX(id), 0)"))
97
102
  [count, max]
98
103
  end
99
104
 
@@ -111,7 +116,7 @@ module Dials
111
116
  # write). The facade uses this to keep uncommitted dial state out of
112
117
  # the shared cache.
113
118
  def transaction_open?
114
- pool = Entry.connection_pool
119
+ pool = @model.connection_pool
115
120
  return false unless pool.active_connection?
116
121
 
117
122
  connection = pool.respond_to?(:lease_connection) ? pool.lease_connection : pool.connection
@@ -166,7 +171,7 @@ module Dials
166
171
  end
167
172
 
168
173
  def changes(key: nil, limit: 50)
169
- relation = Entry.order(id: :desc).limit(limit)
174
+ relation = @model.order(id: :desc).limit(limit)
170
175
  relation = relation.where(key: key.to_s) if key
171
176
  rows = relation.to_a
172
177
  previous = predecessors_of(rows)
@@ -176,7 +181,7 @@ module Dials
176
181
  # action or a noncanonical scope is a row written around the gem,
177
182
  # and the two views must agree on which rows are valid.
178
183
  unless %w[set clear].include?(row.action)
179
- next quarantine("dials(id #{row.id})", "unknown action #{row.action.inspect}")
184
+ next quarantine("#{@model.table_name}(id #{row.id})", "unknown action #{row.action.inspect}")
180
185
  end
181
186
 
182
187
  parsed_scope =
@@ -203,7 +208,7 @@ module Dials
203
208
  rescue StandardError => e
204
209
  # Same quarantine rule as state: one corrupt row (written around
205
210
  # the gem) must not take down the whole history listing.
206
- quarantine("dials(id #{row.id})", "row does not decode (#{e.class})")
211
+ quarantine("#{@model.table_name}(id #{row.id})", "row does not decode (#{e.class})")
207
212
  nil
208
213
  end
209
214
  end
@@ -212,7 +217,7 @@ module Dials
212
217
 
213
218
  # The newest row of one (key, scope) stream, live or not.
214
219
  def newest(key, canonical_scope)
215
- Entry.where(key: key.to_s, scope: canonical_scope).order(seq: :desc).first
220
+ @model.where(key: key.to_s, scope: canonical_scope).order(seq: :desc).first
216
221
  end
217
222
 
218
223
  def live?(row)
@@ -221,20 +226,24 @@ module Dials
221
226
 
222
227
  # Every stream's newest row, in one query. The correlated NOT EXISTS
223
228
  # is portable across PostgreSQL, MySQL, and SQLite (no window
224
- # functions) and walks the (key, scope, seq) index.
229
+ # functions) and walks the (key, scope, seq) index. The table name is
230
+ # quoted by the adapter: this is the one place the gem writes SQL by
231
+ # hand, so a table named for a reserved word must not become a syntax
232
+ # error the first time a namespace loads its state.
225
233
  def newest_rows
226
- Entry.where(<<~SQL.squish)
234
+ table = @model.quoted_table_name
235
+ @model.where(<<~SQL.squish)
227
236
  NOT EXISTS (
228
- SELECT 1 FROM #{Entry.table_name} newer
229
- WHERE newer.key = #{Entry.table_name}.key
230
- AND newer.scope = #{Entry.table_name}.scope
231
- AND newer.seq > #{Entry.table_name}.seq
237
+ SELECT 1 FROM #{table} newer
238
+ WHERE newer.key = #{table}.key
239
+ AND newer.scope = #{table}.scope
240
+ AND newer.seq > #{table}.seq
232
241
  )
233
242
  SQL
234
243
  end
235
244
 
236
245
  def append(key, canonical_scope, seq, action, encoded_value, actor)
237
- Entry.create!(
246
+ @model.create!(
238
247
  key: key.to_s,
239
248
  scope: canonical_scope,
240
249
  seq: seq,
@@ -255,7 +264,7 @@ module Dials
255
264
  return {} if wanted.empty?
256
265
 
257
266
  wanted.group_by { |k, s, _| [k, s] }
258
- .map { |(k, s), triples| Entry.where(key: k, scope: s, seq: triples.map(&:last)) }
267
+ .map { |(k, s), triples| @model.where(key: k, scope: s, seq: triples.map(&:last)) }
259
268
  .reduce(:or)
260
269
  .index_by { |r| [r.key, r.scope, r.seq] }
261
270
  end
@@ -291,7 +300,7 @@ module Dials
291
300
  def write(expected_version, &)
292
301
  attempts = 0
293
302
  begin
294
- Entry.transaction(&)
303
+ @model.transaction(&)
295
304
  rescue ::ActiveRecord::RecordNotUnique
296
305
  if expected_version
297
306
  raise StaleWrite,
@@ -344,15 +353,15 @@ module Dials
344
353
  def valid_scope_string?(key, scope)
345
354
  parsed = JSON.parse(scope)
346
355
  unless parsed.is_a?(Hash) && !parsed.empty?
347
- quarantine("dials(#{key})", "scope #{scope.inspect} is not a non-empty JSON object")
356
+ quarantine("#{@model.table_name}(#{key})", "scope #{scope.inspect} is not a non-empty JSON object")
348
357
  return false
349
358
  end
350
359
  return true if Scope.canonical(Scope.parse(scope)) == scope
351
360
 
352
- quarantine("dials(#{key})", "scope #{scope.inspect} is not canonical")
361
+ quarantine("#{@model.table_name}(#{key})", "scope #{scope.inspect} is not canonical")
353
362
  false
354
363
  rescue JSON::ParserError, InvalidScope
355
- quarantine("dials(#{key})", "scope #{scope.inspect} is not a valid canonical scope")
364
+ quarantine("#{@model.table_name}(#{key})", "scope #{scope.inspect} is not a valid canonical scope")
356
365
  false
357
366
  end
358
367
 
data/lib/dials/actor.rb CHANGED
@@ -13,8 +13,8 @@ module Dials
13
13
  module Actor
14
14
  module_function
15
15
 
16
- def normalize(actor)
17
- actor = default_actor if actor.nil?
16
+ def normalize(actor, config)
17
+ actor = default_actor(config) if actor.nil?
18
18
  if actor.nil?
19
19
  raise MissingActor, "every write requires an actor: (who is making this change?) — " \
20
20
  "pass actor:, or set config.default_actor for apps without user identity"
@@ -23,14 +23,14 @@ module Dials
23
23
  {
24
24
  actor_type: actor_type(actor),
25
25
  actor_id: actor_id(actor),
26
- actor_label: Dials.config.actor_label.call(actor).to_s
26
+ actor_label: config.actor_label.call(actor).to_s
27
27
  }
28
28
  end
29
29
 
30
30
  # The configured fallback; a callable is evaluated per write (so
31
31
  # `-> { ENV.fetch("USER", "console") }` names whoever runs the console).
32
- def default_actor
33
- configured = Dials.config.default_actor
32
+ def default_actor(config)
33
+ configured = config.default_actor
34
34
  configured.respond_to?(:call) ? configured.call : configured
35
35
  end
36
36
 
data/lib/dials/config.rb CHANGED
@@ -1,14 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dials
4
- # App-wide configuration, set once at boot via Dials.configure.
4
+ # One namespace's options, set once at boot via Dials.configure (the root)
5
+ # or the block Dials.namespace takes.
6
+ #
7
+ # An option a namespace does not set reads through to the root's: an engine
8
+ # that configures nothing but its store still honours the app's cache_ttl,
9
+ # actor_label, and default_actor. Where the namespace's values live is the
10
+ # one option with rules of its own — see Storage.
5
11
  class Config
12
+ def initialize(namespace, storage, parent: nil)
13
+ @namespace = namespace
14
+ @storage = storage
15
+ @parent = parent
16
+ @explicit = {}
17
+ @label = nil
18
+ end
19
+
6
20
  # Seconds between staleness probes (see Cache). 0 probes every read;
7
21
  # nil never probes.
8
- attr_reader :cache_ttl
22
+ def cache_ttl
23
+ inherited_option(:cache_ttl) { 5.0 }
24
+ end
9
25
 
10
26
  # Builds the human label stored on every change-log entry.
11
- attr_accessor :actor_label
27
+ def actor_label
28
+ inherited_option(:actor_label) { Actor::DEFAULT_LABEL }
29
+ end
12
30
 
13
31
  # Fallback attribution for writes that pass no actor: — for apps without
14
32
  # user identity (no User model, single-operator tools, scripts). A
@@ -17,60 +35,87 @@ module Dials
17
35
  # actor: required on every write. This is a declared app-level fallback,
18
36
  # not discovery — the gem still never guesses (no Current.user magic),
19
37
  # and an explicit actor: always wins.
20
- attr_accessor :default_actor
38
+ def default_actor
39
+ inherited_option(:default_actor) { nil }
40
+ end
21
41
 
22
- # Prefix for the gem-owned table, mirroring Rails' table_name_prefix
23
- # convention: used verbatim, so include the trailing underscore
24
- # ("ops_" makes the table "ops_dials"). nil (the default) keeps "dials".
25
- # Set it when "dials" collides with an existing table, and pass the same
26
- # prefix to the install generator (--table-name-prefix) so the migration
27
- # matches.
28
- attr_reader :table_name_prefix
42
+ # How this namespace is named on an admin surface. Never inherited —
43
+ # every namespace needs a name of its own.
44
+ def label
45
+ @label || @namespace.default_label
46
+ end
47
+
48
+ attr_writer :label
49
+
50
+ def cache_ttl=(seconds)
51
+ @explicit[:cache_ttl] = seconds
52
+ @namespace.apply_cache_ttl
53
+ end
29
54
 
30
- def initialize
31
- @store = nil
32
- @cache_ttl = 5.0
33
- @actor_label = Actor::DEFAULT_LABEL
34
- @default_actor = nil
35
- @table_name_prefix = nil
55
+ def actor_label=(builder)
56
+ @explicit[:actor_label] = builder
36
57
  end
37
58
 
38
- # Order-independent with store=: whichever runs second applies the name.
39
- def table_name_prefix=(prefix)
40
- @table_name_prefix = prefix
41
- apply_table_name
42
- Dials.reset_cache!
59
+ def default_actor=(actor)
60
+ @explicit[:default_actor] = actor
43
61
  end
44
62
 
45
- def cache_ttl=(seconds)
46
- @cache_ttl = seconds
47
- Dials.cache.ttl = seconds
63
+ # True when this config declares the option itself rather than reading
64
+ # the root's.
65
+ def explicitly_set?(option)
66
+ @explicit.key?(option)
48
67
  end
49
68
 
69
+ # -- storage -------------------------------------------------------------
70
+
50
71
  # Accepts a store instance, or the symbols :memory / :active_record.
51
72
  def store=(store)
52
- @store = case store
53
- when :memory then Stores::Memory.new
54
- when :active_record
55
- require "dials/active_record"
56
- apply_table_name
57
- Stores::ActiveRecordStore.new
58
- else store
59
- end
60
- Dials.reset_cache!
73
+ @storage.kind = store
74
+ @namespace.apply_store
61
75
  end
62
76
 
63
77
  def store
64
- @store ||= Stores::Memory.new
78
+ @storage.store
79
+ end
80
+
81
+ # The table this namespace's ActiveRecord store owns; a namespace never
82
+ # shares one.
83
+ def table_name
84
+ @storage.table_name
85
+ end
86
+
87
+ def table_name=(name)
88
+ @storage.table_name = name
89
+ @namespace.reset_cache!
90
+ end
91
+
92
+ # Prefix for the ROOT's table, mirroring Rails' table_name_prefix
93
+ # convention: used verbatim, so include the trailing underscore
94
+ # ("ops_" makes the table "ops_dials"). nil (the default) keeps "dials".
95
+ # Set it when "dials" collides with an existing table, and pass the same
96
+ # prefix to the install generator (--table-name-prefix) so the migration
97
+ # matches. A non-root namespace names its table with table_name instead.
98
+ def table_name_prefix
99
+ @storage.table_name_prefix
100
+ end
101
+
102
+ def table_name_prefix=(prefix)
103
+ unless @namespace.root?
104
+ raise Error, "table_name_prefix names the root's table; namespace #{@namespace.name} " \
105
+ "names its own with config.table_name"
106
+ end
107
+
108
+ @storage.table_name_prefix = prefix
109
+ @namespace.reset_cache!
65
110
  end
66
111
 
67
112
  private
68
113
 
69
- def apply_table_name
70
- return unless defined?(Dials::ActiveRecord::Entry)
114
+ def inherited_option(name)
115
+ return @explicit[name] if @explicit.key?(name)
116
+ return @parent.public_send(name) if @parent
71
117
 
72
- Dials::ActiveRecord::Entry.table_name =
73
- "#{@table_name_prefix}#{Dials::ActiveRecord::Entry::DEFAULT_TABLE_NAME}"
118
+ yield
74
119
  end
75
120
  end
76
121
  end
data/lib/dials/errors.rb CHANGED
@@ -46,4 +46,22 @@ module Dials
46
46
  # essentially never at operator write rates. Safe to retry; carries no
47
47
  # staleness meaning (that is StaleWrite).
48
48
  class WriteConflict < Error; end
49
+
50
+ # Raised when a namespace is declared twice. A namespace owns a registry
51
+ # and a table; a silent second declaration would make "whose dials are
52
+ # these?" ambiguous.
53
+ class DuplicateNamespace < Error; end
54
+
55
+ # Raised when a namespace is fetched that was never declared.
56
+ class UnknownNamespace < Error; end
57
+
58
+ # Raised when a namespace name's segments are not lowercase letters and
59
+ # digits each starting with a letter (see Namespace::NAME_FORMAT).
60
+ class InvalidNamespace < Error; end
61
+
62
+ # Raised when a namespace's table name is not a plain identifier, is
63
+ # longer than a database will keep whole, or is already another
64
+ # namespace's table. A namespace owns its table; sharing one would
65
+ # interleave two subsystems' rows with nothing to tell them apart.
66
+ class InvalidTableName < Error; end
49
67
  end
@@ -11,50 +11,53 @@ module Dials
11
11
  # all day, so it pays no prefix tax; the writers carry their verbs. These
12
12
  # are real methods defined at declaration time — never method_missing — so
13
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.
14
+ # whose name collides with an existing method on its namespace (:store,
15
+ # :cache, :changes, ...) fails at boot rather than shadowing the API.
16
16
  #
17
17
  # Scope travels as bare keywords here (`market: "KE"`), which is why
18
18
  # `actor` and `expected_version` are reserved dimension names: on
19
19
  # adjust_/clear_ they must always mean attribution and stale-write
20
20
  # protection, never scope. Definition enforces the reservation.
21
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.
22
+ # The methods live in a module the namespace extends (never on the
23
+ # namespace directly) so Registry#reset! can strip every generated method
24
+ # without touching the core API. Each namespace owns its own module, so
25
+ # two namespaces can declare the same key.
25
26
  module Generated
26
27
  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)
28
+ # Define the three methods for a definition in `into`, a module the
29
+ # namespace extends. Collisions are checked first — all three names
30
+ # against every owner, including private methods, since a method on
31
+ # the owner itself would shadow anything defined here — so a raise
32
+ # leaves nothing half-installed. `owners` maps the name a user would
33
+ # type ("Dials") to the object that answers it.
34
+ def install!(definition, into:, owners:)
32
35
  key = definition.key
33
36
  names = [key, :"adjust_#{key}", :"clear_#{key}"]
34
37
 
35
38
  names.each do |name|
36
- next unless Dials.respond_to?(name, true)
39
+ owners.each do |label, owner|
40
+ next unless owner.respond_to?(name, true)
37
41
 
38
- raise InvalidDefinition, "dial #{key} would define Dials.#{name}, which already exists"
42
+ raise InvalidDefinition, "dial #{key} would define #{label}.#{name}, which already exists"
43
+ end
39
44
  end
40
45
 
41
46
  # actor: defaults to nil rather than being a required keyword so that
42
47
  # apps declaring config.default_actor can write without one; with no
43
48
  # 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|
49
+ into.define_method(names[0]) { |**scope| get(key, **scope) }
50
+ into.define_method(names[1]) do |value, actor: nil, expected_version: nil, **scope|
46
51
  set(key, value, actor: actor, scope: scope, expected_version: expected_version)
47
52
  end
48
- define_method(names[2]) do |actor: nil, expected_version: nil, **scope|
53
+ into.define_method(names[2]) do |actor: nil, expected_version: nil, **scope|
49
54
  clear(key, actor: actor, scope: scope, expected_version: expected_version)
50
55
  end
51
56
  end
52
57
 
53
- def uninstall_all!
54
- instance_methods(false).each { |name| remove_method(name) }
58
+ def uninstall_all!(from:)
59
+ from.instance_methods(false).each { |name| from.remove_method(name) }
55
60
  end
56
61
  end
57
62
  end
58
-
59
- extend Generated
60
63
  end