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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 616a841d2067499823a017f9b83bf151c2a7df9128ac044154c37a473a275770
4
+ data.tar.gz: 6d32168d1c2c5bf45742db53b95d1eace0a145ec046e911a21eba1231660812b
5
+ SHA512:
6
+ metadata.gz: 149a10e661a7fc3ca2504d06f33e5c69294c9445373542d66970bb340e6313968170486c1d2184a927b7c08d223bbd20864474b4811c18272adb5b8cbe2459f4
7
+ data.tar.gz: 74f42bde26e31ed6c2f8ee2f0591c100d921d4282072718c5650237e59027a0981d7c3e454436047f20b6536fb241772c0ec37d93c02c1b52608fe4a73b53a87
data/CHANGELOG.md ADDED
@@ -0,0 +1,76 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-09-02
4
+
5
+ Initial release.
6
+
7
+ - **Code-declared registry.** `Dials.define` with
8
+ `dial :checkout_fee_bps, default: 250, type: :integer, minimum: 1, maximum: 10_000`
9
+ — types (boolean, integer, float, string, json) plus labels, units, and
10
+ descriptions. Constraints speak JSON Schema directly (snake_cased keywords:
11
+ `minimum:`/`maximum:`/`exclusive_minimum:`/`exclusive_maximum:`/
12
+ `multiple_of:` for numbers, `min_length:`/`max_length:`/`pattern:` for
13
+ strings, `enum:` for any type, `properties:`/`required:` for `:json`
14
+ objects), with `validate:` (a callable) as the escape hatch for rules a
15
+ schema cannot express. `Definition#to_json_schema` emits each declaration
16
+ as a real JSON Schema fragment for admin surfaces and agents.
17
+ - **Generated per-dial methods are the primary API.** Declaring a dial
18
+ defines real methods at declaration time: readers are the bare dial name
19
+ (`Dials.checkout_fee_bps(market: "KE")`), writers keep their verbs
20
+ (`Dials.adjust_checkout_fee_bps(value, actor:, **scope)` and
21
+ `Dials.clear_checkout_fee_bps(actor:, **scope)`), so a bare name is always
22
+ a read. The key-taking primitives (`Dials.get` / `Dials.set` /
23
+ `Dials.clear`) remain public as the dynamic-access layer for code that
24
+ receives the key at runtime. Name collisions with existing `Dials` methods
25
+ raise `InvalidDefinition`; `actor` and `expected_version` are reserved
26
+ dimension names.
27
+ - **Dimensions and scoped overrides.** `dimensions:` declares a dial's axes
28
+ (closed `enum:` option lists or open, length-capped values). The database
29
+ stores overrides only; resolution is scoped override → global override →
30
+ code default, with a most-specific-wins matcher. Clearing an override
31
+ returns resolution to the layer below.
32
+ - **The log is the state — one append-only table.** Every write INSERTs
33
+ exactly one row into a single `dials` table; the newest row per
34
+ (key, scope) stream is the current override (`set` carries a value,
35
+ `clear` ends it). Current state, attributed history (`Dials.changes`),
36
+ and the cache's version counter are the same rows, so history can never
37
+ disagree with state, and `changes` derives old values from the previous
38
+ row instead of trusting a stored copy.
39
+ - **Stale-write protection (compare-and-swap).** Every write path accepts
40
+ `expected_version:` — an opaque per-override token from `Dials.overview`
41
+ or a previous CAS write's return value. Tokens are stream `seq` numbers
42
+ claimed under `UNIQUE(key, scope, seq)`, so of two concurrent claims the
43
+ database rejects one: the comparison is atomic with the write, holds
44
+ against every concurrent writer without anyone opting in, and unrelated
45
+ overrides can never false-conflict. A mismatch raises `Dials::StaleWrite`
46
+ with the write unapplied and nothing logged, and is deliberately never
47
+ auto-retried. Cleared overrides keep a tombstone token, so
48
+ `Dials::ABSENT_VERSION` strictly means "never written" and absent
49
+ assertions cannot be fooled by set-then-clear activity (no ABA).
50
+ - **Attributed writes.** Writers require `actor:` and every write lands in
51
+ the append-only log. Apps without a user identity can declare
52
+ `config.default_actor` (a value, or a callable evaluated per write) once;
53
+ an explicit `actor:` always wins.
54
+ - **Enumeration API.** `Dials.overview` returns every registered dial's full
55
+ state — definition (with its JSON Schema), global override, scoped
56
+ overrides, and CAS tokens — from one snapshot stamped with a single
57
+ version token; `Dials.scoped_overrides(key)` returns one dial's stored
58
+ overrides keyed by parsed scope. Both read through the same path as the
59
+ generated readers and return frozen structures.
60
+ - **Per-process snapshot cache** with a throttled staleness probe
61
+ (`cache_ttl`), single-flight refreshes, and a last-known-good snapshot
62
+ served (with a warning) when the store blips. Writes inside an application
63
+ database transaction never leak uncommitted state into the shared cache —
64
+ the writing thread reads its own view until commit, and the cache busts
65
+ again on commit.
66
+ - **Hardened by adversarial review.** Corrupt rows written around the gem
67
+ are quarantined with a warning instead of failing reads; values
68
+ JSON-round-trip identically in both stores (no retained caller references,
69
+ no store-dependent shapes); declaration defaults are deep-frozen; write
70
+ retries cover deadlocks and serialization failures but never run inside an
71
+ outer application transaction.
72
+ - **Stores:** in-memory (default, zero dependencies) and ActiveRecord
73
+ (Rails/AR >= 7.2, portable JSON-text columns), with a
74
+ `rails g dials:install` generator (migration + initializer).
75
+ - **`Dials::Testing.with_overrides`** for client test suites.
76
+ - **Zero runtime dependencies.**
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 ZAR Labs Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # dials
2
+
3
+ Operator-adjustable values with per-scope overrides, attribution, and
4
+ caching.
5
+
6
+ You wrote a constant. Then you needed to change it without a deploy, so you
7
+ built an admin surface. Then you needed a different value per market. Dials is
8
+ that whole arc as one small library:
9
+
10
+ ```ruby
11
+ # config/initializers/dials.rb
12
+ Dials.define do
13
+ dial :merchant_fee_bps, default: 100,
14
+ type: :integer, minimum: 1, maximum: 10_000, unit: "bps",
15
+ dimensions: { market: { enum: %w[KE NG BD] } }
16
+
17
+ dial :signups_enabled, default: true, type: :boolean,
18
+ description: "Global kill switch for new signups."
19
+ end
20
+ ```
21
+
22
+ The constraint keywords are **JSON Schema**, snake_cased (`minimum:`,
23
+ `maximum:`, `enum:`, `pattern:`, `properties:`/`required:` for `:json`
24
+ values) — if you've written an OpenAPI spec or a JSON Schema, you already
25
+ know this vocabulary, and `definition.to_json_schema` hands the real
26
+ fragment to admin UIs and client-side validators.
27
+
28
+ Each declaration generates the dial's methods:
29
+
30
+ ```ruby
31
+ Dials.merchant_fee_bps(market: "KE") # => 100 (the code default)
32
+
33
+ Dials.adjust_merchant_fee_bps(90, actor: current_admin, market: "KE")
34
+ Dials.merchant_fee_bps(market: "KE") # => 90
35
+ Dials.merchant_fee_bps(market: "NG") # => 100
36
+
37
+ Dials.clear_merchant_fee_bps(actor: current_admin, market: "KE")
38
+ Dials.merchant_fee_bps(market: "KE") # => 100 again
39
+
40
+ Dials.changes(key: :merchant_fee_bps) # attributed, append-only history
41
+ ```
42
+
43
+ The key-taking primitives (`Dials.get`, `Dials.set`, `Dials.clear`) stay
44
+ public underneath, for code that receives the key at runtime — an admin
45
+ surface iterating the registry, a console one-liner.
46
+
47
+ Resolution is always **scoped override → global override → code default**.
48
+ The database is one append-only table storing only overrides — state,
49
+ attributed history, and the cache's version counter are the same rows, and
50
+ clearing every override returns you to exactly what the code says. Reads
51
+ come from a per-process cache with a throttled staleness probe, so a dial
52
+ read costs a hash lookup, not a query.
53
+
54
+ ## Installation
55
+
56
+ ```bash
57
+ bundle add dials
58
+ bin/rails generate dials:install # migration (3 tables) + initializer
59
+ bin/rails db:migrate
60
+ ```
61
+
62
+ Full documentation: <https://zarpay.github.io/dials/> — including the design
63
+ decisions, the caching model, retrofit guides for existing apps, and when a
64
+ value should *not* be a dial.
65
+
66
+ ## Development
67
+
68
+ ```bash
69
+ bundle install
70
+ bundle exec rake # minitest + rubocop
71
+ ```
72
+
73
+ The sibling `demo/` package in this repository is a Rails app whose test
74
+ suite exercises the entire public API against a real database.
75
+
76
+ ## License
77
+
78
+ MIT.
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
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
7
+ # ever queries inside a value or a scope — reads go through the
8
+ # in-process cache, so the database is durable storage, not a query
9
+ # surface.
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
+
16
+ # One row per WRITE — the table is append-only, so the change log IS the
17
+ # state. The newest row per (key, scope) stream is the current override:
18
+ # action "set" carries the value; action "clear" says the override is
19
+ # gone (resolution falls to the next layer). A global override is simply
20
+ # the stream at the empty scope, stored under the canonical encoding
21
+ # "{}". History cannot disagree with state because they are the same
22
+ # rows, and an override's previous value is literally its previous row.
23
+ #
24
+ # `seq` numbers each stream's rows 1, 2, 3...; UNIQUE(key, scope, seq)
25
+ # is what makes writes atomic without locks or guarded updates: every
26
+ # writer claims the stream's next slot, and of two concurrent claims the
27
+ # database rejects one. Rows are immutable and seq only grows, so a
28
+ # stale-write token (the live row's seq) can never be revisited.
29
+ #
30
+ # NOTE: uniqueness is textual, under the column's collation. Scope
31
+ # strings are canonical (sorted keys, string values) so gem writes can
32
+ # never collide cosmetically; on MySQL, a case-insensitive default
33
+ # collation additionally treats scopes differing only by case ("KE" vs
34
+ # "ke") as one stream — don't declare dimension enums that differ only
35
+ # by case, or give the table a binary collation.
36
+ class Entry < ::ActiveRecord::Base
37
+ self.table_name = "dials"
38
+
39
+ validates :key, :scope, :seq, presence: true
40
+ validates :action, presence: true, inclusion: { in: %w[set clear] }
41
+ validates :value, presence: true, if: -> { action == "set" }
42
+
43
+ # App-level append-only: a persisted row refuses update and destroy
44
+ # through ActiveRecord. (Raw SQL and delete_all can still bypass this —
45
+ # rows are state, history, AND the cache's version counter, so don't.)
46
+ def readonly?
47
+ persisted?
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,365 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
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.
11
+ #
12
+ # Concurrency control is the stream sequence: each row claims its
13
+ # stream's next `seq` under UNIQUE(key, scope, seq), so of two concurrent
14
+ # writes the database rejects one — atomic, with no lock table, no
15
+ # advisory locks, and no guarded updates. A rejected unconditional write
16
+ # re-runs with fresh reads; a rejected compare-and-swap re-runs, sees the
17
+ # interleaved write, and raises StaleWrite. Rows are immutable and seq
18
+ # only grows, so a stale-write token (the live row's seq) can never be
19
+ # revisited by a later delete-and-recreate.
20
+ class ActiveRecordStore
21
+ Entry = Dials::ActiveRecord::Entry
22
+
23
+ # Sentinel for "this row could not be decoded; skip it".
24
+ SKIP = Object.new
25
+
26
+ # Database races a write can lose and safely re-run: two writers
27
+ # claiming the same seq (RecordNotUnique — the re-run reads the new
28
+ # newest row), and adapter-reported deadlocks / serialization failures
29
+ # (TransactionRollbackError covers both). StaleWrite is deliberately
30
+ # NOT here — a retried CAS would recompute against the new state and
31
+ # silently defeat the mechanism (the re-run raises it AFTER re-reading,
32
+ # which is the correct outcome, not a retry of the comparison).
33
+ RETRYABLE = [
34
+ ::ActiveRecord::RecordNotUnique,
35
+ ::ActiveRecord::TransactionRollbackError
36
+ ].freeze
37
+
38
+ # Attempts per write: the first, plus retries for lost seq claims.
39
+ # Operator write rates make even the second attempt rare.
40
+ WRITE_ATTEMPTS = 3
41
+
42
+ def state
43
+ # Version first: if a write lands between these reads, the snapshot
44
+ # carries an older version than its data, and the next probe sees the
45
+ # version move and rebuilds — stale in the safe direction only.
46
+ current_version = version
47
+
48
+ globals = {}
49
+ scoped = {}
50
+ row_versions = {}
51
+ newest_rows.each do |row|
52
+ case row.action
53
+ when "clear"
54
+ # Tombstones carry no value but DO carry the stream's stale-write
55
+ # stamp — an "absent" token must go stale when set/clear activity
56
+ # happened since it was read. Their scopes are validated like any
57
+ # other row's: a malformed tombstone scope must be quarantined
58
+ # here, not crash Dials.overview when it tries to parse the stamp
59
+ # map later.
60
+ if row.scope == Scope::GLOBAL || valid_scope_string?(row.key, row.scope)
61
+ (row_versions[row.key.to_sym] ||= {})[row.scope] = row.seq
62
+ end
63
+ next
64
+ when "set"
65
+ nil # fall through to the value path
66
+ else
67
+ quarantine("dials(#{row.key}, #{row.scope})", "unknown action #{row.action.inspect}")
68
+ next
69
+ end
70
+
71
+ value = decode_row(row.value, "dials(#{row.key}, #{row.scope})")
72
+ next if value.equal?(SKIP)
73
+
74
+ if row.scope == Scope::GLOBAL
75
+ globals[row.key.to_sym] = value
76
+ elsif valid_scope_string?(row.key, row.scope)
77
+ (scoped[row.key.to_sym] ||= {})[row.scope] = value
78
+ else
79
+ next
80
+ end
81
+ (row_versions[row.key.to_sym] ||= {})[row.scope] = row.seq
82
+ end
83
+
84
+ { globals: globals, scoped_overrides: scoped, version: current_version, row_versions: row_versions }
85
+ end
86
+
87
+ # The table is append-only, so its row count moves on every committed
88
+ # write and only ever grows. Count alone would be enough; max id is
89
+ # included as a belt against direct table surgery. Max id alone would
90
+ # NOT be enough: transaction A can claim id 10, B claim and commit id
91
+ # 11, and only then A commits — MAX(id) never moves for a process that
92
+ # already saw 11, so A's write would stay invisible until the next
93
+ # unrelated write. Count catches it (N → N+1). Both aggregates come
94
+ # from ONE statement so they describe one committed state, never two.
95
+ def version
96
+ count, max = Entry.pick(Arel.sql("COUNT(*)"), Arel.sql("COALESCE(MAX(id), 0)"))
97
+ [count, max]
98
+ end
99
+
100
+ # The stale-write stamp of one override stream: the seq of its newest
101
+ # row, LIVE OR TOMBSTONE — a cleared override keeps its clear row's
102
+ # stamp, so an absent → set → clear cycle can never make an old
103
+ # "absent" token current again (no ABA). Only a stream with no rows at
104
+ # all is 0, the StoreVersion::ABSENT state.
105
+ def override_version(key, canonical_scope)
106
+ newest(key, canonical_scope)&.seq || 0
107
+ end
108
+
109
+ # True when the current thread's connection is inside an open
110
+ # transaction (typically an application transaction wrapping a dial
111
+ # write). The facade uses this to keep uncommitted dial state out of
112
+ # the shared cache.
113
+ def transaction_open?
114
+ pool = Entry.connection_pool
115
+ return false unless pool.active_connection?
116
+
117
+ connection = pool.respond_to?(:lease_connection) ? pool.lease_connection : pool.connection
118
+ connection.transaction_open?
119
+ end
120
+
121
+ # Runs the block after the current application transaction commits
122
+ # (immediately when no transaction is open). Discarded on rollback.
123
+ # The AR >= 7.2 floor enforced at require time guarantees the hook
124
+ # exists.
125
+ def after_commit(&)
126
+ if transaction_open?
127
+ ::ActiveRecord.after_all_transactions_commit(&)
128
+ else
129
+ yield
130
+ end
131
+ end
132
+
133
+ # The two mutations. `canonical_scope` is always a canonical string —
134
+ # Scope::GLOBAL for the global override. Both return [result, seq]:
135
+ # the usual result (previous value / did-anything-exist) plus the
136
+ # stream's seq after the write, so the facade can mint the caller's
137
+ # next token from the row it KNOWS was written — never from a second
138
+ # read that a concurrent writer could slip in front of.
139
+ def set_override(key, canonical_scope, value, actor, expected_version: nil)
140
+ write(expected_version) do
141
+ row = newest(key, canonical_scope)
142
+ assert_version!(expected_version, row&.seq || 0)
143
+
144
+ old = live?(row) ? decode(row.value) : nil
145
+ seq = (row&.seq || 0) + 1
146
+ append(key, canonical_scope, seq, "set", encode(value), actor)
147
+ [old, seq]
148
+ end
149
+ end
150
+
151
+ # Clearing appends a "clear" row, ending the stream's live override —
152
+ # resolution falls to the next layer, and the history of how it got
153
+ # there is preserved. Clearing what is not live is a no-op and appends
154
+ # nothing (but the version comparison, if requested, already ran: a
155
+ # stale no-op is still stale).
156
+ def clear_override(key, canonical_scope, actor, expected_version: nil)
157
+ write(expected_version) do
158
+ row = newest(key, canonical_scope)
159
+ assert_version!(expected_version, row&.seq || 0)
160
+ next [false, row&.seq || 0] unless live?(row)
161
+
162
+ seq = row.seq + 1
163
+ append(key, canonical_scope, seq, "clear", nil, actor)
164
+ [true, seq]
165
+ end
166
+ end
167
+
168
+ def changes(key: nil, limit: 50)
169
+ relation = Entry.order(id: :desc).limit(limit)
170
+ relation = relation.where(key: key.to_s) if key
171
+ rows = relation.to_a
172
+ previous = predecessors_of(rows)
173
+
174
+ rows.filter_map do |row|
175
+ # History applies the same quarantine rules as state: an unknown
176
+ # action or a noncanonical scope is a row written around the gem,
177
+ # and the two views must agree on which rows are valid.
178
+ unless %w[set clear].include?(row.action)
179
+ next quarantine("dials(id #{row.id})", "unknown action #{row.action.inspect}")
180
+ end
181
+
182
+ parsed_scope =
183
+ if row.scope == Scope::GLOBAL
184
+ nil
185
+ elsif valid_scope_string?(row.key, row.scope)
186
+ Scope.parse(row.scope)
187
+ else
188
+ next nil
189
+ end
190
+
191
+ pred = previous[[row.key, row.scope, row.seq - 1]]
192
+ ChangeRecord.new(
193
+ key: row.key.to_sym,
194
+ scope: parsed_scope,
195
+ action: row.action,
196
+ old_value: pred && pred.action == "set" ? decode(pred.value) : nil,
197
+ new_value: row.action == "set" ? decode(row.value) : nil,
198
+ actor_type: row.actor_type,
199
+ actor_id: row.actor_id,
200
+ actor_label: row.actor_label,
201
+ created_at: row.created_at
202
+ )
203
+ rescue StandardError => e
204
+ # Same quarantine rule as state: one corrupt row (written around
205
+ # the gem) must not take down the whole history listing.
206
+ quarantine("dials(id #{row.id})", "row does not decode (#{e.class})")
207
+ nil
208
+ end
209
+ end
210
+
211
+ private
212
+
213
+ # The newest row of one (key, scope) stream, live or not.
214
+ def newest(key, canonical_scope)
215
+ Entry.where(key: key.to_s, scope: canonical_scope).order(seq: :desc).first
216
+ end
217
+
218
+ def live?(row)
219
+ !row.nil? && row.action == "set"
220
+ end
221
+
222
+ # Every stream's newest row, in one query. The correlated NOT EXISTS
223
+ # is portable across PostgreSQL, MySQL, and SQLite (no window
224
+ # functions) and walks the (key, scope, seq) index.
225
+ def newest_rows
226
+ Entry.where(<<~SQL.squish)
227
+ 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
232
+ )
233
+ SQL
234
+ end
235
+
236
+ def append(key, canonical_scope, seq, action, encoded_value, actor)
237
+ Entry.create!(
238
+ key: key.to_s,
239
+ scope: canonical_scope,
240
+ seq: seq,
241
+ action: action,
242
+ value: encoded_value,
243
+ actor_type: actor[:actor_type],
244
+ actor_id: actor[:actor_id],
245
+ actor_label: actor[:actor_label]
246
+ )
247
+ end
248
+
249
+ # The predecessor row (seq - 1) for each listed change, fetched with
250
+ # one exact predicate per stream (no Cartesian over-fetch across
251
+ # unrelated keys/scopes) — old_value is derived from history itself,
252
+ # so history cannot disagree with what was actually replaced.
253
+ def predecessors_of(rows)
254
+ wanted = rows.filter_map { |r| [r.key, r.scope, r.seq - 1] if r.seq > 1 }
255
+ return {} if wanted.empty?
256
+
257
+ wanted.group_by { |k, s, _| [k, s] }
258
+ .map { |(k, s), triples| Entry.where(key: k, scope: s, seq: triples.map(&:last)) }
259
+ .reduce(:or)
260
+ .index_by { |r| [r.key, r.scope, r.seq] }
261
+ end
262
+
263
+ # The compare half of compare-and-swap; `current` is the stream's
264
+ # newest seq, tombstones included (ABSENT strictly means "this stream
265
+ # was never written"). The comparison itself is a read; atomicity
266
+ # comes from the seq claim under UNIQUE(key, scope, seq) — a writer
267
+ # that interleaves between this check and our INSERT takes our slot,
268
+ # our INSERT raises RecordNotUnique, and write() converts that
269
+ # directly to StaleWrite for CAS callers (a lost claim PROVES an
270
+ # interleaver). Nothing applied, nothing logged.
271
+ def assert_version!(expected, current)
272
+ return if expected.nil? || expected == StoreVersion.token(current)
273
+
274
+ raise StaleWrite,
275
+ "the override has changed since version #{expected} was read — " \
276
+ "re-read (Dials.overview) and retry deliberately"
277
+ end
278
+
279
+ # A CAS write that loses its seq claim is STALE by definition — the
280
+ # lost claim proves a concurrent write landed after the version was
281
+ # read — so RecordNotUnique converts straight to StaleWrite, with no
282
+ # retry and no re-read (correct even inside an aborted outer
283
+ # transaction, where re-reading is impossible). Unconditional writes
284
+ # re-run the WHOLE transaction with fresh reads — but only when we are
285
+ # NOT inside an application transaction: after a database error there,
286
+ # the outer transaction is in an aborted state (PostgreSQL) and
287
+ # re-running statements would fail differently — the error must
288
+ # propagate to whoever owns that transaction. A seq claim that loses
289
+ # every attempt surfaces as WriteConflict (unconditional writes racing
290
+ # each other — essentially never at operator rates).
291
+ def write(expected_version, &)
292
+ attempts = 0
293
+ begin
294
+ Entry.transaction(&)
295
+ rescue ::ActiveRecord::RecordNotUnique
296
+ if expected_version
297
+ raise StaleWrite,
298
+ "a concurrent write landed after version #{expected_version} was read — re-read (Dials.overview) and retry deliberately"
299
+ end
300
+
301
+ attempts += 1
302
+ retry if attempts < WRITE_ATTEMPTS && !transaction_open?
303
+ raise WriteConflict, "concurrent writes kept racing this override — safe to retry"
304
+ rescue ::ActiveRecord::TransactionRollbackError
305
+ attempts += 1
306
+ retry if attempts < WRITE_ATTEMPTS && !transaction_open?
307
+ raise
308
+ end
309
+ end
310
+
311
+ def encode(value)
312
+ JSON.generate(value)
313
+ end
314
+
315
+ # Values round-trip through JSON, so a :json dial's hash keys come back
316
+ # as strings — the same value a JSON API would hand you. Scalar types
317
+ # (boolean, integer, float, string) round-trip exactly.
318
+ def decode(raw)
319
+ JSON.parse(raw)
320
+ end
321
+
322
+ # Rows written around the gem (console surgery, bad imports) must not
323
+ # take down every dial read in the process: a row that does not decode
324
+ # to a legal stored value is skipped with a warning, and every other
325
+ # dial keeps resolving.
326
+ def decode_row(raw, where)
327
+ value = decode(raw)
328
+ if value.nil?
329
+ quarantine(where, "stored value is JSON null")
330
+ return SKIP
331
+ end
332
+
333
+ value
334
+ rescue TypeError, JSON::ParserError
335
+ quarantine(where, "stored value is not valid JSON")
336
+ SKIP
337
+ end
338
+
339
+ # For non-global streams only — the global's "{}" was matched before
340
+ # this runs, so an empty object here is corrupt (a hand-written row).
341
+ # Canonical exactness is required, not just shape: a noncanonical
342
+ # spelling ({"b":1,"a":2}, spaces, non-string values) would be a
343
+ # stream the resolver can never match against a canonicalized request.
344
+ def valid_scope_string?(key, scope)
345
+ parsed = JSON.parse(scope)
346
+ unless parsed.is_a?(Hash) && !parsed.empty?
347
+ quarantine("dials(#{key})", "scope #{scope.inspect} is not a non-empty JSON object")
348
+ return false
349
+ end
350
+ return true if Scope.canonical(Scope.parse(scope)) == scope
351
+
352
+ quarantine("dials(#{key})", "scope #{scope.inspect} is not canonical")
353
+ false
354
+ rescue JSON::ParserError, InvalidScope
355
+ quarantine("dials(#{key})", "scope #{scope.inspect} is not a valid canonical scope")
356
+ false
357
+ end
358
+
359
+ def quarantine(where, reason)
360
+ warn "[dials] skipping corrupt row in #{where}: #{reason} (fix or delete the row; it was not written through the Dials API)"
361
+ nil
362
+ end
363
+ end
364
+ end
365
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "dials"
5
+
6
+ # The adapter's cache-coherence contract for writes inside application
7
+ # transactions depends on ActiveRecord.after_all_transactions_commit
8
+ # (added in 7.2). On older ActiveRecord there is no safe commit-time bust:
9
+ # a mid-transaction republish of pre-commit state could outlive the commit
10
+ # indefinitely. Failing loudly here beats shipping that silent gap.
11
+ if Gem::Version.new(ActiveRecord::VERSION::STRING) < Gem::Version.new("7.2")
12
+ raise Dials::Error,
13
+ "dials/active_record requires ActiveRecord >= 7.2 " \
14
+ "(found #{ActiveRecord::VERSION::STRING}); the :memory store works on any version"
15
+ end
16
+
17
+ require_relative "active_record/models"
18
+ require_relative "active_record/store"
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dials
4
+ # Normalizes whatever the caller passes as `actor:` into the three strings
5
+ # the change log stores. An ActiveRecord-ish object contributes its class
6
+ # name and id; a plain string is stored as the label; anything else uses
7
+ # its class and to_s. The label can be customized app-wide via
8
+ # `Dials.configure { |c| c.actor_label = ->(actor) { actor.email } }`.
9
+ #
10
+ # A nil actor falls back to `config.default_actor` (for apps without user
11
+ # identity); when that is also nil/absent, MissingActor — there is no
12
+ # anonymous mutation path the app didn't explicitly declare.
13
+ module Actor
14
+ module_function
15
+
16
+ def normalize(actor)
17
+ actor = default_actor if actor.nil?
18
+ if actor.nil?
19
+ raise MissingActor, "every write requires an actor: (who is making this change?) — " \
20
+ "pass actor:, or set config.default_actor for apps without user identity"
21
+ end
22
+
23
+ {
24
+ actor_type: actor_type(actor),
25
+ actor_id: actor_id(actor),
26
+ actor_label: Dials.config.actor_label.call(actor).to_s
27
+ }
28
+ end
29
+
30
+ # The configured fallback; a callable is evaluated per write (so
31
+ # `-> { ENV.fetch("USER", "console") }` names whoever runs the console).
32
+ def default_actor
33
+ configured = Dials.config.default_actor
34
+ configured.respond_to?(:call) ? configured.call : configured
35
+ end
36
+
37
+ def actor_type(actor)
38
+ actor.is_a?(String) ? nil : actor.class.name
39
+ end
40
+
41
+ def actor_id(actor)
42
+ actor.respond_to?(:id) ? actor.id.to_s : nil
43
+ end
44
+
45
+ DEFAULT_LABEL = lambda do |actor|
46
+ if actor.is_a?(String)
47
+ actor
48
+ elsif actor.respond_to?(:email) && actor.email
49
+ actor.email
50
+ elsif actor.respond_to?(:name) && actor.name
51
+ actor.name
52
+ else
53
+ [actor.class.name, actor.respond_to?(:id) ? actor.id : nil].compact.join("#")
54
+ end
55
+ end
56
+ end
57
+ end