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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +76 -0
- data/LICENSE.txt +21 -0
- data/README.md +78 -0
- data/lib/dials/active_record/models.rb +51 -0
- data/lib/dials/active_record/store.rb +365 -0
- data/lib/dials/active_record.rb +18 -0
- data/lib/dials/actor.rb +57 -0
- data/lib/dials/cache.rb +158 -0
- data/lib/dials/change_record.rb +16 -0
- data/lib/dials/config.rb +50 -0
- data/lib/dials/definition.rb +226 -0
- data/lib/dials/dimension.rb +60 -0
- data/lib/dials/errors.rb +49 -0
- data/lib/dials/freeze.rb +20 -0
- data/lib/dials/generated.rb +60 -0
- data/lib/dials/overview.rb +45 -0
- data/lib/dials/railtie.rb +10 -0
- data/lib/dials/registry.rb +68 -0
- data/lib/dials/resolver.rb +45 -0
- data/lib/dials/schema.rb +247 -0
- data/lib/dials/scope.rb +106 -0
- data/lib/dials/snapshot.rb +30 -0
- data/lib/dials/store_version.rb +34 -0
- data/lib/dials/stores/memory.rb +175 -0
- data/lib/dials/testing.rb +40 -0
- data/lib/dials/version.rb +5 -0
- data/lib/dials.rb +308 -0
- data/lib/generators/dials/install/install_generator.rb +39 -0
- data/lib/generators/dials/install/templates/initializer.rb.tt +49 -0
- data/lib/generators/dials/install/templates/migration.rb.tt +39 -0
- metadata +82 -0
|
@@ -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
|
data/lib/dials/schema.rb
ADDED
|
@@ -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
|
data/lib/dials/scope.rb
ADDED
|
@@ -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
|