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 +4 -4
- data/CHANGELOG.md +57 -0
- data/README.md +15 -0
- data/lib/dials/active_record/models.rb +23 -13
- data/lib/dials/active_record/store.rb +37 -28
- data/lib/dials/actor.rb +5 -5
- data/lib/dials/config.rb +84 -39
- data/lib/dials/errors.rb +18 -0
- data/lib/dials/generated.rb +22 -19
- data/lib/dials/namespace.rb +402 -0
- data/lib/dials/registry.rb +14 -12
- data/lib/dials/storage.rb +153 -0
- data/lib/dials/testing.rb +25 -13
- data/lib/dials/version.rb +1 -1
- data/lib/dials.rb +133 -201
- data/lib/generators/dials/install/install_generator.rb +1 -1
- data/lib/generators/dials/install/templates/initializer.rb.tt +2 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b86f0661acabf1181381d1b427daa25ab6cfa3a165bfa0df5cc8aef73bb0f3e6
|
|
4
|
+
data.tar.gz: fe4aeaa5c53ec38b2bc23232ef780ebd535992dcc806664e50a064aac8c86356
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
6
|
-
#
|
|
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
|
-
#
|
|
12
|
-
# Application code reads and writes through the Dials
|
|
13
|
-
#
|
|
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
|
|
37
|
-
|
|
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
|
|
6
|
-
# Dials::ActiveRecord::
|
|
7
|
-
# per (key, scope) stream is the current override, and the
|
|
8
|
-
# the attributed history and the cache's version counter.
|
|
9
|
-
# override is the stream at Scope::GLOBAL (the canonical empty
|
|
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("
|
|
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, "
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
234
|
+
table = @model.quoted_table_name
|
|
235
|
+
@model.where(<<~SQL.squish)
|
|
227
236
|
NOT EXISTS (
|
|
228
|
-
SELECT 1 FROM #{
|
|
229
|
-
WHERE newer.key = #{
|
|
230
|
-
AND newer.scope = #{
|
|
231
|
-
AND newer.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
|
-
|
|
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|
|
|
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
|
-
|
|
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("
|
|
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("
|
|
361
|
+
quarantine("#{@model.table_name}(#{key})", "scope #{scope.inspect} is not canonical")
|
|
353
362
|
false
|
|
354
363
|
rescue JSON::ParserError, InvalidScope
|
|
355
|
-
quarantine("
|
|
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:
|
|
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 =
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
+
def default_actor
|
|
39
|
+
inherited_option(:default_actor) { nil }
|
|
40
|
+
end
|
|
21
41
|
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
31
|
-
@
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
@
|
|
53
|
-
|
|
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
|
|
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
|
|
70
|
-
return
|
|
114
|
+
def inherited_option(name)
|
|
115
|
+
return @explicit[name] if @explicit.key?(name)
|
|
116
|
+
return @parent.public_send(name) if @parent
|
|
71
117
|
|
|
72
|
-
|
|
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
|
data/lib/dials/generated.rb
CHANGED
|
@@ -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
|
|
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
|
|
23
|
-
#
|
|
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
|
|
28
|
-
# first — all three names
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
|
|
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
|
-
|
|
39
|
+
owners.each do |label, owner|
|
|
40
|
+
next unless owner.respond_to?(name, true)
|
|
37
41
|
|
|
38
|
-
|
|
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
|