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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # Present so future Rails integration points (rake tasks, reload hooks)
5
+ # have a home. Generators under lib/generators are discovered by Rails on
6
+ # their own; requiring the gem inside a Rails app is enough for
7
+ # `bin/rails generate dials:install` to work.
8
+ class Railtie < Rails::Railtie
9
+ end
10
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
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.
8
+ #
9
+ # Declarations accumulate across `Dials.define` blocks (so large apps can
10
+ # split declarations by domain), but a key declared twice raises — a dial's
11
+ # declaration is its single source of truth.
12
+ class Registry
13
+ include Enumerable
14
+
15
+ def initialize
16
+ @definitions = {}
17
+ @mutex = Mutex.new
18
+ end
19
+
20
+ # 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.
25
+ def dial(key, **)
26
+ definition = Definition.new(key, **)
27
+ @mutex.synchronize do
28
+ raise DuplicateDial, "dial #{definition.key} is already defined" if @definitions.key?(definition.key)
29
+
30
+ Generated.install!(definition)
31
+ @definitions[definition.key] = definition
32
+ end
33
+ definition
34
+ end
35
+
36
+ def fetch(key)
37
+ @definitions.fetch(key.to_sym) do
38
+ raise UnknownDial, "no dial defined for #{key.inspect} (defined: #{keys.join(', ')})"
39
+ end
40
+ end
41
+
42
+ def defined?(key)
43
+ @definitions.key?(key.to_sym)
44
+ end
45
+
46
+ def keys
47
+ @definitions.keys
48
+ end
49
+
50
+ def each(&)
51
+ @definitions.values.each(&)
52
+ end
53
+
54
+ def all
55
+ @definitions.values
56
+ end
57
+
58
+ # Test hook: wipe every declaration and its generated methods. Production
59
+ # code has no reason to call this; a registry that shrinks at runtime
60
+ # would strand stored rows.
61
+ def reset!
62
+ @mutex.synchronize do
63
+ @definitions.clear
64
+ Generated.uninstall_all!
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # Resolution: scoped override → global override → code default.
5
+ #
6
+ # The matcher is deliberately more general than v1 needs. A stored scope
7
+ # matches a request when every pair it names is present in the request
8
+ # (subset match), and the most specific match — the one naming the most
9
+ # dimensions — wins, with ties broken by the dial's declared dimension
10
+ # order. Under the v1 write rule (stored scopes always name every declared
11
+ # dimension) this degenerates to exact-match-or-global, but relaxing the
12
+ # write rule later enables partial scopes ({market: "KE"} covering every
13
+ # platform) with no change here.
14
+ module Resolver
15
+ module_function
16
+
17
+ # `scope` is already normalized and validated. Returns the resolved value.
18
+ def resolve(definition, scope, snapshot)
19
+ stored = snapshot.scoped_overrides[definition.key]
20
+ if stored && !stored.empty? && !scope.empty?
21
+ match = best_match(definition, scope, stored)
22
+ return match[1] if match
23
+ end
24
+
25
+ snapshot.globals.fetch(definition.key) { definition.default }
26
+ end
27
+
28
+ def best_match(definition, scope, stored)
29
+ candidates = stored.filter_map do |canonical, value|
30
+ stored_scope = Scope.parse(canonical)
31
+ next unless stored_scope.all? { |name, v| scope[name] == v }
32
+
33
+ [stored_scope, value]
34
+ end
35
+ return nil if candidates.empty?
36
+
37
+ priority = definition.dimension_names.each_with_index.to_h
38
+ candidates.max_by do |stored_scope, _value|
39
+ # More named dimensions wins; among equals, earlier-declared
40
+ # dimensions outrank later ones (compared most-significant first).
41
+ [stored_scope.size, definition.dimension_names.map { |n| stored_scope.key?(n) ? priority.size - priority[n] : 0 }]
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # A dial's value constraints, spoken in JSON Schema's vocabulary
5
+ # (snake_cased for Ruby): `enum`; `minimum` / `maximum` /
6
+ # `exclusive_minimum` / `exclusive_maximum` / `multiple_of` for numbers;
7
+ # `min_length` / `max_length` / `pattern` for strings; `properties` /
8
+ # `required` for :json objects (with `items` available inside nested array
9
+ # schemas). Borrowing the standard's words means no bespoke vocabulary
10
+ # decisions when the API grows, and constraints are declarative data an
11
+ # admin surface can render — see #to_json_schema. Rules a schema cannot
12
+ # express use the dial's `validate:` callable instead (see Definition).
13
+ #
14
+ # Nested schemas (inside properties/items) must declare a `type:` — the
15
+ # value checks are type-driven, and an untyped nested constraint would
16
+ # silently skip them (JSON Schema's "keywords ignore mismatched types" rule
17
+ # is exactly the footgun this gem's boot-time strictness exists to avoid).
18
+ # For the same reason, declaring `properties`/`required` on a :json dial
19
+ # pins its values to JSON objects.
20
+ class Schema
21
+ NUMERIC_KEYWORDS = %i[minimum maximum exclusive_minimum exclusive_maximum multiple_of].freeze
22
+ STRING_KEYWORDS = %i[min_length max_length pattern].freeze
23
+ OBJECT_KEYWORDS = %i[properties required].freeze
24
+
25
+ # Keywords a dial declaration may use, by dial type.
26
+ DIAL_KEYWORDS = {
27
+ boolean: [:enum].freeze,
28
+ integer: ([:enum] + NUMERIC_KEYWORDS).freeze,
29
+ float: ([:enum] + NUMERIC_KEYWORDS).freeze,
30
+ string: ([:enum] + STRING_KEYWORDS).freeze,
31
+ json: ([:enum] + OBJECT_KEYWORDS).freeze
32
+ }.freeze
33
+
34
+ # Types and keywords available inside nested schemas. These are JSON
35
+ # Schema's own type names (:number, :object, :array), not the dial types.
36
+ NESTED_KEYWORDS = {
37
+ boolean: [:enum].freeze,
38
+ integer: ([:enum] + NUMERIC_KEYWORDS).freeze,
39
+ number: ([:enum] + NUMERIC_KEYWORDS).freeze,
40
+ string: ([:enum] + STRING_KEYWORDS).freeze,
41
+ object: ([:enum] + OBJECT_KEYWORDS).freeze,
42
+ array: %i[enum items].freeze
43
+ }.freeze
44
+
45
+ TYPE_CHECKS = {
46
+ boolean: ["must be true or false", ->(v) { [true, false].include?(v) }].freeze,
47
+ integer: ["must be an integer", ->(v) { v.is_a?(Integer) }].freeze,
48
+ number: ["must be a number", ->(v) { v.is_a?(Integer) || v.is_a?(Float) }].freeze,
49
+ string: ["must be a string", ->(v) { v.is_a?(String) }].freeze,
50
+ object: ["must be a JSON object", ->(v) { v.is_a?(Hash) }].freeze,
51
+ array: ["must be an array", ->(v) { v.is_a?(Array) }].freeze
52
+ }.freeze
53
+
54
+ CAMEL = {
55
+ exclusive_minimum: "exclusiveMinimum", exclusive_maximum: "exclusiveMaximum",
56
+ multiple_of: "multipleOf", min_length: "minLength", max_length: "maxLength"
57
+ }.freeze
58
+
59
+ def initialize(key, type, constraints)
60
+ @key = key
61
+ @constraints = normalize!(constraints, allowed: DIAL_KEYWORDS.fetch(type), path: nil)
62
+ Freeze.deep(@constraints)
63
+ freeze
64
+ end
65
+
66
+ def empty?
67
+ @constraints.empty?
68
+ end
69
+
70
+ # True when the schema constrains :json values to objects
71
+ # (properties/required declared) — used for the emitted "type".
72
+ def object?
73
+ @constraints.key?(:properties) || @constraints.key?(:required)
74
+ end
75
+
76
+ # Validation problems for a type-checked value; [] when it conforms.
77
+ def problems_for(value)
78
+ problems(@constraints, value, nil)
79
+ end
80
+
81
+ # The constraints as a JSON Schema fragment (camelCase keywords, pattern
82
+ # as its regexp source). The dial's `validate:` callable, if any, is not
83
+ # representable here — that is the deal it offers.
84
+ def to_json_schema
85
+ render(@constraints)
86
+ end
87
+
88
+ private
89
+
90
+ # -- declaration-time normalization --------------------------------------
91
+
92
+ def normalize!(constraints, allowed:, path:)
93
+ if constraints.key?(:bounds)
94
+ boom(path, "bounds: was replaced by JSON Schema keywords " \
95
+ "(minimum:/maximum:/enum:/pattern:/... — validate: for arbitrary rules)")
96
+ end
97
+
98
+ unknown = constraints.keys - allowed
99
+ unless unknown.empty?
100
+ boom(path, "unknown keyword#{'s' if unknown.size > 1} #{unknown.join(', ')} " \
101
+ "(allows: #{allowed.join(', ')})")
102
+ end
103
+
104
+ constraints.to_h { |keyword, spec| [keyword, normalize_keyword!(keyword, spec, path)] }
105
+ end
106
+
107
+ def normalize_keyword!(keyword, spec, path)
108
+ case keyword
109
+ when :enum
110
+ boom(path, "enum must be a non-empty Array") unless spec.is_a?(Array) && !spec.empty?
111
+ spec.dup
112
+ when :minimum, :maximum, :exclusive_minimum, :exclusive_maximum
113
+ boom(path, "#{keyword} must be a number") unless spec.is_a?(Numeric)
114
+ spec
115
+ when :multiple_of
116
+ boom(path, "multiple_of must be a positive number") unless spec.is_a?(Numeric) && spec.positive?
117
+ spec
118
+ when :min_length, :max_length
119
+ boom(path, "#{keyword} must be a non-negative integer") unless spec.is_a?(Integer) && spec >= 0
120
+ spec
121
+ when :pattern then normalize_pattern!(spec, path)
122
+ when :properties then normalize_properties!(spec, path)
123
+ when :required then normalize_required!(spec, path)
124
+ when :items then normalize_nested!(spec, join(path, "items"))
125
+ end
126
+ end
127
+
128
+ def normalize_pattern!(spec, path)
129
+ case spec
130
+ when Regexp then spec
131
+ when String
132
+ begin
133
+ Regexp.new(spec)
134
+ rescue RegexpError => e
135
+ boom(path, "pattern is not a valid regexp (#{e.message})")
136
+ end
137
+ else
138
+ boom(path, "pattern must be a Regexp or String")
139
+ end
140
+ end
141
+
142
+ def normalize_properties!(spec, path)
143
+ boom(path, "properties must be a Hash of name => schema") unless spec.is_a?(Hash)
144
+
145
+ spec.to_h { |name, sub| [name.to_s, normalize_nested!(sub, join(path, name))] }
146
+ end
147
+
148
+ def normalize_required!(spec, path)
149
+ unless spec.is_a?(Array) && spec.all? { |k| k.is_a?(String) || k.is_a?(Symbol) }
150
+ boom(path, "required must be an Array of key names")
151
+ end
152
+ spec.map(&:to_s)
153
+ end
154
+
155
+ def normalize_nested!(spec, path)
156
+ boom(path, "schema must be a Hash") unless spec.is_a?(Hash)
157
+
158
+ spec = spec.transform_keys(&:to_sym)
159
+ type = spec[:type]&.to_sym
160
+ allowed = NESTED_KEYWORDS.fetch(type) do
161
+ boom(path, "schema needs a type: (one of #{NESTED_KEYWORDS.keys.join(', ')})")
162
+ end
163
+
164
+ { type: type }.merge(normalize!(spec.except(:type), allowed: allowed, path: path))
165
+ end
166
+
167
+ def join(path, name)
168
+ path ? "#{path}.#{name}" : name.to_s
169
+ end
170
+
171
+ def boom(path, message)
172
+ raise InvalidDefinition, ["#{@key}:", path, message].compact.join(" ")
173
+ end
174
+
175
+ # -- value validation -----------------------------------------------------
176
+
177
+ def problems(cons, value, path)
178
+ if cons[:type]
179
+ problem = type_problem(cons[:type], value)
180
+ return [at(path, problem)] if problem
181
+ elsif (cons.key?(:properties) || cons.key?(:required)) && !value.is_a?(Hash)
182
+ # Dial-level :json only; nested schemas carry an explicit type.
183
+ return [at(path, "must be a JSON object (its schema declares properties)")]
184
+ end
185
+
186
+ out = []
187
+ out << at(path, "must be one of #{cons[:enum].inspect}") if cons.key?(:enum) && !cons[:enum].include?(value)
188
+ out << at(path, "must be >= #{cons[:minimum]}") if cons.key?(:minimum) && value < cons[:minimum]
189
+ out << at(path, "must be <= #{cons[:maximum]}") if cons.key?(:maximum) && value > cons[:maximum]
190
+ if cons.key?(:exclusive_minimum) && value <= cons[:exclusive_minimum]
191
+ out << at(path, "must be > #{cons[:exclusive_minimum]}")
192
+ end
193
+ if cons.key?(:exclusive_maximum) && value >= cons[:exclusive_maximum]
194
+ out << at(path, "must be < #{cons[:exclusive_maximum]}")
195
+ end
196
+ if cons.key?(:multiple_of) && !(value % cons[:multiple_of]).zero?
197
+ out << at(path, "must be a multiple of #{cons[:multiple_of]}")
198
+ end
199
+ if cons.key?(:min_length) && value.length < cons[:min_length]
200
+ out << at(path, "must be at least #{cons[:min_length]} characters")
201
+ end
202
+ if cons.key?(:max_length) && value.length > cons[:max_length]
203
+ out << at(path, "must be at most #{cons[:max_length]} characters")
204
+ end
205
+ out << at(path, "must match #{cons[:pattern].inspect}") if cons.key?(:pattern) && !cons[:pattern].match?(value)
206
+
207
+ out.concat(object_problems(cons, value, path)) if value.is_a?(Hash)
208
+ if cons[:items] && value.is_a?(Array)
209
+ value.each_with_index { |element, i| out.concat(problems(cons[:items], element, "#{path}[#{i}]")) }
210
+ end
211
+ out
212
+ end
213
+
214
+ def object_problems(cons, value, path)
215
+ out = (cons[:required] || []).filter_map do |name|
216
+ at(path, "is missing required key #{name.inspect}") unless value.key?(name)
217
+ end
218
+ (cons[:properties] || {}).each do |name, sub|
219
+ out.concat(problems(sub, value[name], join(path, name))) if value.key?(name)
220
+ end
221
+ out
222
+ end
223
+
224
+ def type_problem(type, value)
225
+ message, check = TYPE_CHECKS.fetch(type)
226
+ message unless check.call(value)
227
+ end
228
+
229
+ def at(path, message)
230
+ path ? "#{path} #{message}" : message
231
+ end
232
+
233
+ # -- serialization --------------------------------------------------------
234
+
235
+ def render(cons)
236
+ cons.each_with_object({}) do |(keyword, spec), out|
237
+ case keyword
238
+ when :type then out["type"] = spec.to_s
239
+ when :pattern then out["pattern"] = spec.source
240
+ when :properties then out["properties"] = spec.transform_values { |sub| render(sub) }
241
+ when :items then out["items"] = render(spec)
242
+ else out[CAMEL.fetch(keyword, keyword.to_s)] = spec
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Dials
6
+ # Scope handling: validation against a dial's declared dimensions and a
7
+ # canonical string form for storage and lookup.
8
+ #
9
+ # The canonical form is a JSON object with sorted keys and string values —
10
+ # `{market: :KE}` and `{"market" => "KE"}` both canonicalize to
11
+ # `{"market":"KE"}` — so a scope written once can never be re-stored under a
12
+ # cosmetically different spelling. Uniqueness lives on (key, canonical
13
+ # scope) in whatever store persists it.
14
+ #
15
+ # v1 write rule: a scoped override names ALL of its dial's declared
16
+ # dimensions (exact scope). The matching code in Resolver is already
17
+ # general (subset match, most-specific wins), so partial scopes are a
18
+ # planned write-side relaxation, not a redesign. See docs/design.
19
+ module Scope
20
+ # The canonical encoding of the EMPTY scope — what Scope.canonical({})
21
+ # returns. Storage uses it as the scope of a global override: a global is
22
+ # simply the override that constrains no dimensions. This is the truthful
23
+ # encoding of a real value in the scope algebra, not a sentinel invented
24
+ # outside it.
25
+ GLOBAL = "{}"
26
+
27
+ module_function
28
+
29
+ # Normalize a caller-supplied scope hash into {symbol => string}. A hash
30
+ # naming the same dimension twice under different spellings
31
+ # ({"market" => "KE", market: "NG"}) is a caller bug — one value would
32
+ # silently win by insertion order — so it raises instead.
33
+ def normalize(scope)
34
+ scope ||= {}
35
+ normalized = scope.to_h { |k, v| [k.to_sym, v.to_s] }
36
+ if normalized.size != scope.size
37
+ raise InvalidScope, "scope names the same dimension more than once: #{scope.keys.inspect}"
38
+ end
39
+
40
+ normalized
41
+ end
42
+
43
+ # Stored canonical scopes land in an indexed VARCHAR(255); a longer
44
+ # string would fail (or truncate and collide) at the database on some
45
+ # adapters, so it fails validation here instead.
46
+ MAX_CANONICAL_BYTES = 255
47
+
48
+ # Canonical storage/lookup string for a normalized scope.
49
+ def canonical(scope)
50
+ result = JSON.generate(normalize(scope).sort.to_h { |k, v| [k.to_s, v] })
51
+ if result.bytesize > MAX_CANONICAL_BYTES
52
+ raise InvalidScope, "canonical scope exceeds #{MAX_CANONICAL_BYTES} bytes: #{result[0, 80]}…"
53
+ end
54
+
55
+ result
56
+ end
57
+
58
+ # Inverse of .canonical — used when loading stored rows. A stored scope
59
+ # that is valid JSON but not an object ("42", "[]") is corrupt data, not
60
+ # a scope; raising InvalidScope lets loaders quarantine the row.
61
+ def parse(canonical_string)
62
+ parsed = JSON.parse(canonical_string)
63
+ raise InvalidScope, "stored scope is not a JSON object: #{canonical_string.inspect}" unless parsed.is_a?(Hash)
64
+
65
+ parsed.to_h { |k, v| [k.to_sym, v] }
66
+ end
67
+
68
+ # Validate a scope against a definition. `exact:` requires every declared
69
+ # dimension to be present (the v1 rule for both reads and writes);
70
+ # without it, any subset of declared dimensions passes (the future
71
+ # partial-scope rule). Raises InvalidScope; returns the normalized hash.
72
+ def validate!(definition, scope, exact: true)
73
+ normalized = normalize(scope)
74
+
75
+ if definition.dimensions.empty?
76
+ return normalized if normalized.empty?
77
+
78
+ raise InvalidScope, "dial #{definition.key} declares no dimensions; scope #{normalized.inspect} is not allowed"
79
+ end
80
+
81
+ declared = definition.dimensions.to_h { |d| [d.name, d] }
82
+
83
+ normalized.each_key do |name|
84
+ next if declared.key?(name)
85
+
86
+ raise InvalidScope, "dial #{definition.key} has no dimension #{name} (declares: #{declared.keys.join(', ')})"
87
+ end
88
+
89
+ if exact
90
+ missing = declared.keys - normalized.keys
91
+ unless missing.empty?
92
+ raise InvalidScope, "dial #{definition.key} requires scope for: #{missing.join(', ')}"
93
+ end
94
+ end
95
+
96
+ normalized.each do |name, value|
97
+ dimension = declared.fetch(name)
98
+ next if dimension.valid_value?(value)
99
+
100
+ raise InvalidScope, "#{value.inspect} is not a valid #{name} for dial #{definition.key}"
101
+ end
102
+
103
+ normalized
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # An immutable point-in-time copy of every stored override.
5
+ #
6
+ # globals:: { key(Symbol) => value } — only dials with a stored global
7
+ # override appear; absence means "inherit the code default".
8
+ # scoped_overrides:: { key(Symbol) => { canonical_scope(String) => value } }
9
+ # row_versions:: { key(Symbol) => { canonical_scope(String) => Integer } }
10
+ # — the per-override version stamps (the global's under
11
+ # Scope::GLOBAL), for stale-write tokens.
12
+ # version:: the store's monotonic write counter at load time.
13
+ #
14
+ # Values are deep-frozen: reads hand out references into the shared
15
+ # snapshot, and a caller mutating a returned :json value must not be able
16
+ # to corrupt what every other thread reads.
17
+ class Snapshot
18
+ attr_reader :globals, :scoped_overrides, :row_versions, :version
19
+
20
+ def initialize(globals:, scoped_overrides:, version:, row_versions: {})
21
+ @globals = Freeze.deep(globals)
22
+ @scoped_overrides = Freeze.deep(scoped_overrides)
23
+ @row_versions = Freeze.deep(row_versions)
24
+ @version = version
25
+ freeze
26
+ end
27
+ end
28
+
29
+ Snapshot::EMPTY = Snapshot.new(globals: {}, scoped_overrides: {}, version: 0)
30
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # Per-override version tokens (opaque frozen Strings, safe to round-trip
5
+ # through JSON, HTML forms, and HTTP params). Every stored override row
6
+ # carries a version stamped from its last change-log entry — monotonic
7
+ # across the whole store, so a row deleted and re-created can never revisit
8
+ # an old version (no ABA). "No override" has the well-known token ABSENT.
9
+ #
10
+ # Callers obtain tokens from Dials.overview (or as the return value of a
11
+ # write that carried `expected_version:`) and echo them back on writes —
12
+ # they never construct or parse one, so the representation stays free to
13
+ # change. Comparison is plain string equality; a token from a different
14
+ # store shape (or garbage) simply never matches and surfaces as StaleWrite,
15
+ # the safe direction.
16
+ module StoreVersion
17
+ module_function
18
+
19
+ def token(raw)
20
+ JSON.generate(raw).freeze
21
+ end
22
+
23
+ # The token of an override stream that has NEVER been written. A cleared
24
+ # override is not ABSENT — its tombstone keeps a stamp (handed out by
25
+ # Dials.overview), so "absent because cleared" and "absent because never
26
+ # written" can never be confused, and an old ABSENT token goes stale the
27
+ # moment any write touches the stream.
28
+ #
29
+ # A token minted by a write inside a database transaction that later
30
+ # ROLLS BACK is void — it describes a write that never happened; discard
31
+ # it with the rest of the transaction's effects.
32
+ ABSENT = token(0)
33
+ end
34
+ end