concerns_on_rails 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d0ca1b10759cf285948bef864e3cba343b3d1caa5e461b000af0486b4d51781
4
- data.tar.gz: 1006768d58216c32a190c551ede050eb2e12ac24d13daf847600e83d1f219f52
3
+ metadata.gz: dbe5bebe66a84e336f6dbffff99b12d12ed65f6238ea000b9a149908b1a78e0c
4
+ data.tar.gz: 8478287a2750b501288e02e30394cfb79a45e2208190c14e8fce945457229e2b
5
5
  SHA512:
6
- metadata.gz: 7b8c99595c6fbdd34ba4eb2ed011dee168c6178cffbee5bd1803195e83d5e2301bb87db99bc2dbf1143ba6d5e584f708d44aefbbea13248af2bc0d6eba0772d2
7
- data.tar.gz: b43057a24fd64599ee62ef31dcbddd0daf3b985193a36dd02811c17721ebebf0e73f5793c73ffb41c98524ca9b5bec1a100c9c3619ccf8ceb896ce5ce11051cc
6
+ metadata.gz: 06bd9e4b6a3493047ea48b60f2ff75517bb3d117c8cbe4712071f7464db173c8cb73f7a3763d22a4376f458ba1d85deffca47648544ec4879642d14dc40e6a78
7
+ data.tar.gz: 042252611fb6ef6a41c671d3cf2a17e29ee7dcf82349f8f84b22d04274ebbcd2c48c23a2b6a03291325e0d072daedf29981b84bc2fb0a11a6cf832d5f79a572e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.16.0 (2026-06-10)
4
+
5
+ Two new concerns, hardened by an adversarial edge-case review. 574 examples, 0 failures.
6
+
7
+ ### Added
8
+ - **Models::Auditable**: lightweight single-column change history ("paper_trail-lite"). `auditable_by :price, :status, into: :audit_log, actor: -> { Current.user&.email }, max_entries: 100` appends one JSON entry per changed field per save (creates record `from: nil`) into one text column — no extra tables, written in the same INSERT/UPDATE via `before_save`. Readers: `audit_trail`, `last_change_for(:field)`, `audited_changes_since(time)`, `clear_audit_trail!`. Tolerant JSON decode (corrupt column → `[]`), newest-N trimming (default 200), opt-in value truncation (`max_value_length:` stores the first N characters of long String values + `…`), values JSON-coerced (times → ISO8601 UTC, BigDecimal → precision-safe string, non-finite floats → `"NaN"`/`"Infinity"` strings), `"by"` omitted when no actor. Entries build on the persisted trail, so a save aborted by a later callback cannot duplicate entries on retry. Zero new runtime dependencies.
9
+ - **Controllers::Idempotentable**: Stripe-style `Idempotency-Key` support with an injectable store (`self.idempotency_store = Rails.cache`; contract: `#read`, `#write(expires_in:, unless_exist:)`, `#delete` — no in-process default on purpose). `idempotent_actions :create, ttl: 24.hours, lock_ttl: 1.minute, header: "Idempotency-Key", required: false` claims each key atomically, caches 2xx–4xx responses and replays them with `X-Idempotency-Replayed: true`; concurrent duplicates get 409 + `Retry-After`, payload mismatches get 422 (`idempotency_key_reuse`, fingerprint overridable), 5xx/exceptions release the claim so retries re-execute. Keys are validated (≤255 chars, control characters rejected to prevent response-header injection via the echoed `X-Idempotency-Key`), SHA256-hashed, and scoped per `controller#action`. Error bodies delegate to `render_error` when Respondable is present. Zero new runtime dependencies.
10
+
3
11
  ## 1.15.0 (2026-06-10)
4
12
 
5
13
  A review-driven release: 23 correctness/safety fixes (each with a regression spec) and 7 backward-compatible enhancements. 510 examples, 0 failures.
data/README.md CHANGED
@@ -43,6 +43,7 @@ Article.published.without_deleted.find("hello-world")
43
43
  - [Sanitizable](#-sanitizable) — opt-in HTML sanitization (XSS defense-in-depth)
44
44
  - [Maskable](#-maskable) — non-destructive display masking of sensitive fields
45
45
  - [Monetizable](#-monetizable) — integer-cents money columns (BigDecimal)
46
+ - [Auditable](#-auditable) — single-column change history ("paper_trail-lite")
46
47
  - **Controller concerns**
47
48
  - [Paginatable](#-paginatable) — offset pagination with headers
48
49
  - [Filterable](#-filterable) — declarative URL-param filters
@@ -55,6 +56,7 @@ Article.published.without_deleted.find("hello-world")
55
56
  - [Authorizable](#-authorizable) — per-action 403 authorization gate (block-based)
56
57
  - [Throttleable](#-throttleable) — rate limiting with 429 + `X-RateLimit-*` headers
57
58
  - [Timezoneable](#-timezoneable) — per-request `Time.zone` from params / header / cookie
59
+ - [Idempotentable](#-idempotentable) — `Idempotency-Key` request replay (409 on concurrent duplicates)
58
60
  - [Module paths & namespacing](#-module-paths--namespacing)
59
61
  - [Development](#-development)
60
62
  - [Contributing](#-contributing)
@@ -64,7 +66,7 @@ Article.published.without_deleted.find("hello-world")
64
66
 
65
67
  ## ✨ Why this gem?
66
68
 
67
- - **Eighteen model concerns + eight controller concerns**, all production-ready
69
+ - **Nineteen model concerns + twelve controller concerns**, all production-ready
68
70
  - **One include, one macro** — no boilerplate, no glue code
69
71
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
70
72
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -935,6 +937,39 @@ product.formatted_price # => "$19.99"
935
937
 
936
938
  ---
937
939
 
940
+ ## 📜 Auditable
941
+
942
+ Lightweight change history ("paper_trail-lite") stored as JSON entries in **one text column** on the same table — no extra tables, no versioning engine.
943
+
944
+ ```ruby
945
+ class Product < ApplicationRecord
946
+ include ConcernsOnRails::Auditable
947
+
948
+ auditable_by :price, :status # default column :audit_log
949
+ # auditable_by :price, into: :history,
950
+ # actor: -> { Current.user&.email }, # stamps "by" on each entry
951
+ # max_entries: 50 # keep the newest 50
952
+ end
953
+
954
+ product.update!(price: 200)
955
+ product.audit_trail
956
+ # => [{"field"=>"price", "from"=>100, "to"=>200, "at"=>"2026-06-10T12:34:56Z", "by"=>"admin@shop.com"}]
957
+ product.last_change_for(:price) # newest entry for one field
958
+ product.audited_changes_since(1.day.ago) # recent entries, oldest first
959
+ product.clear_audit_trail! # wipe the column (skips callbacks)
960
+ ```
961
+
962
+ One entry is recorded **per changed field per save** (creates record `"from" => nil`), appended in the same `INSERT`/`UPDATE` via `before_save` — zero extra queries.
963
+
964
+ **Options**: `into:` (`:audit_log`), `actor:` (callable, `instance_exec`'d on the record; `"by"` omitted when absent), `max_entries:` (`200`; keeps the newest N, `nil` = unlimited), `max_value_length:` (`nil`; truncates long String `from`/`to` values to the first N characters + `…`).
965
+
966
+ **Notes**
967
+ - Writes that skip callbacks (`update_column(s)`, `touch`, `increment!`) are **not** audited; `save(validate: false)` is.
968
+ - Values are JSON-coerced (times → ISO8601 UTC strings, `BigDecimal` → precision-safe numeric string); a corrupt column decodes as `[]` and is replaced on the next tracked save.
969
+ - Per-record and bounded by design — reach for [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) when you need reify/undo or audit queries across models.
970
+
971
+ ---
972
+
938
973
  # 🎮 Controller Concerns
939
974
 
940
975
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -1299,6 +1334,33 @@ Resolution order: `params[param]` → `Time-Zone` header → cookie (if enabled)
1299
1334
 
1300
1335
  ---
1301
1336
 
1337
+ ## 🔁 Idempotentable
1338
+
1339
+ Stripe-style **`Idempotency-Key`** support for mutating endpoints, with a **store-agnostic, injectable** backend. The first request with a key runs the action and caches the response; a retry **replays** the cached response; a concurrent duplicate gets **409**.
1340
+
1341
+ ```ruby
1342
+ class PaymentsController < ApplicationController
1343
+ include ConcernsOnRails::Controllers::Idempotentable
1344
+
1345
+ self.idempotency_store = Rails.cache # must support #read / #write(expires_in:, unless_exist:) / #delete
1346
+
1347
+ idempotent_actions :create, ttl: 24.hours, required: true
1348
+ end
1349
+ ```
1350
+
1351
+ Per-key lifecycle: claim atomically (`write unless_exist`, TTL `lock_ttl:`) → run action → cache 2xx–4xx responses for `ttl:`; 5xx and raised exceptions release the claim so the client can retry. Replays carry `X-Idempotency-Replayed: true`; duplicates in flight get 409 + `Retry-After`; reusing a key with a **different payload** gets 422 (`idempotency_key_reuse`, fingerprint overridable via `idempotency_fingerprint`).
1352
+
1353
+ **Options**: `*actions` (allow-list, required), `ttl:` (`24.hours`), `lock_ttl:` (`1.minute`), `header:` (`"Idempotency-Key"`), `required:` (`false`).
1354
+
1355
+ **Notes**
1356
+ - Cache keys are scoped per `controller#action` and the client key is SHA256-hashed, so the same key on different endpoints never collides.
1357
+ - There is **no in-process default store** on purpose: the first keyed request raises `ArgumentError` until you set `idempotency_store`.
1358
+ - When `Respondable` is included, the 400/409/422 bodies delegate to `render_error`.
1359
+ - Declare halting filters (authentication, `Throttleable`) **before** including this concern — a 401/403 rendered by an inner filter would be cached and replayed for the full TTL. Responses rendered by `rescue_from` handlers are never cached.
1360
+ - Keys must be ≤255 chars with no control characters (the raw key is echoed in `X-Idempotency-Key`); set `lock_ttl:` above the slowest declared action's worst case.
1361
+
1362
+ ---
1363
+
1302
1364
  ## 🗂️ Module paths & namespacing
1303
1365
 
1304
1366
  Every concern is available under two paths:
@@ -1334,7 +1396,7 @@ Both forms reference the same module, so you can freely mix them.
1334
1396
  | Association-cascade soft delete / sentinel-aware unique indexes | [`paranoia`](https://github.com/rubysherpas/paranoia) or [`discard`](https://github.com/jhawthorn/discard) |
1335
1397
  | Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
1336
1398
  | Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
1337
- | Audit trails / version history | [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) |
1399
+ | Versioned audit trails with undo/reify, who-dunnit queries, or association tracking | [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) |
1338
1400
 
1339
1401
  `Sluggable` wraps [`friendly_id`](https://github.com/norman/friendly_id) and `Sortable` wraps [`acts_as_list`](https://github.com/brendon/acts_as_list), so you get those leaders' engines behind the declarative macro.
1340
1402
 
@@ -0,0 +1,300 @@
1
+ require "active_support/concern"
2
+ require "digest"
3
+ require "json"
4
+
5
+ module ConcernsOnRails
6
+ module Controllers
7
+ # Stripe-style `Idempotency-Key` support for mutating endpoints, with a
8
+ # store-agnostic, injectable backend. The first request with a key executes
9
+ # the action and caches the rendered response; a retry with the same key
10
+ # replays the cached response instead of re-running the action; a concurrent
11
+ # duplicate while the first is still in flight is halted with 409.
12
+ #
13
+ # class PaymentsController < ApplicationController
14
+ # include ConcernsOnRails::Controllers::Idempotentable
15
+ #
16
+ # self.idempotency_store = Rails.cache # must support #read / #write(expires_in:, unless_exist:) / #delete
17
+ #
18
+ # idempotent_actions :create, ttl: 24.hours, required: true
19
+ # end
20
+ #
21
+ # Lifecycle per key (scoped to controller#action, so the same client key on
22
+ # different endpoints never collides):
23
+ # * claim won -> action runs; 2xx-4xx responses are cached for `ttl:`;
24
+ # 5xx responses and raised exceptions release the claim so
25
+ # the client can retry.
26
+ # * done -> the cached status/body/content type is replayed with
27
+ # `X-Idempotency-Replayed: true`.
28
+ # * in flight -> 409 with code "idempotency_conflict" and `Retry-After`.
29
+ # * same key, different request payload -> 422 "idempotency_key_reuse"
30
+ # (override `idempotency_fingerprint` to customize payload matching).
31
+ #
32
+ # The claim is taken atomically via `write(..., unless_exist: true)`
33
+ # (memcached `add` / Redis `SET NX` through Rails.cache); a store without
34
+ # that atomicity is best-effort under concurrency. There is no in-process
35
+ # default store on purpose — configure one explicitly or the first keyed
36
+ # request raises ArgumentError. Note that responses rendered by
37
+ # `rescue_from` handlers bypass the around filter's success path and are
38
+ # never cached.
39
+ #
40
+ # IMPORTANT — callback ordering: declare halting filters (authentication,
41
+ # authorization, rate limiting) BEFORE including this module. A
42
+ # before_action that runs *inside* the around filter and halts (401/403)
43
+ # has its response cached and replayed for the full `ttl:` — the client
44
+ # cannot fix credentials and retry until it expires. Rails offers no
45
+ # reliable way for the around filter to detect a halted inner chain.
46
+ # Likewise set `lock_ttl:` above the worst-case duration of the slowest
47
+ # declared action — if the action outlasts the claim, a concurrent retry
48
+ # can win the expired key and execute the action a second time.
49
+ module Idempotentable
50
+ extend ActiveSupport::Concern
51
+
52
+ DEFAULT_HEADER = "Idempotency-Key".freeze
53
+ MAX_KEY_LENGTH = 255
54
+ IGNORED_FINGERPRINT_KEYS = %w[controller action format].freeze
55
+
56
+ included do
57
+ class_attribute :idempotency_rules, instance_accessor: false, default: []
58
+ class_attribute :idempotency_store, instance_accessor: false, default: nil
59
+ around_action :enforce_idempotency
60
+ end
61
+
62
+ module ClassMethods
63
+ # Declare idempotent actions. `ttl:` is the cached-response lifetime,
64
+ # `lock_ttl:` the in-flight claim lifetime (kept short so a crashed
65
+ # worker cannot wedge a key), `header:` the request header to read, and
66
+ # `required:` whether a missing key is a 400. Each call appends a rule;
67
+ # the first rule listing the current action wins.
68
+ def idempotent_actions(*actions, ttl: 86_400, lock_ttl: 60, header: DEFAULT_HEADER, required: false)
69
+ actions = actions.flatten.map(&:to_s)
70
+ validate_idempotent!(actions, ttl: ttl, lock_ttl: lock_ttl, header: header, required: required)
71
+
72
+ rule = { actions: actions, ttl: ttl.to_i, lock_ttl: lock_ttl.to_i, header: header.to_s, required: required }
73
+ self.idempotency_rules = idempotency_rules + [rule]
74
+ end
75
+
76
+ private
77
+
78
+ def validate_idempotent!(actions, ttl:, lock_ttl:, header:, required:)
79
+ prefix = "ConcernsOnRails::Controllers::Idempotentable"
80
+ raise ArgumentError, "#{prefix}: pass at least one action" if actions.empty?
81
+ raise ArgumentError, "#{prefix}: :ttl must be a positive duration" unless ttl.to_i.positive?
82
+ raise ArgumentError, "#{prefix}: :lock_ttl must be a positive duration" unless lock_ttl.to_i.positive?
83
+ raise ArgumentError, "#{prefix}: :header must be a non-blank String" if header.to_s.strip.empty?
84
+ raise ArgumentError, "#{prefix}: :required must be true or false" unless [true, false].include?(required)
85
+ end
86
+ end
87
+
88
+ # around_action entry point. Public so subclasses can override and specs
89
+ # can drive it directly with a block standing in for the action.
90
+ def enforce_idempotency(&)
91
+ rule = idempotency_rule_for_action
92
+ return yield unless rule
93
+
94
+ raw = read_idempotency_header(rule)
95
+ return idempotency_handle_missing_key(rule, &) if raw.nil?
96
+
97
+ key = raw.to_s.strip
98
+ unless valid_idempotency_key?(key)
99
+ return idempotency_error_response(message: "#{rule[:header]} is invalid (expected 1-#{MAX_KEY_LENGTH} characters).",
100
+ status: :bad_request, code: "idempotency_key_invalid")
101
+ end
102
+
103
+ @idempotency_key = key
104
+ run_with_idempotency(rule, key, &)
105
+ end
106
+
107
+ # The raw key sent for the matched rule (nil when absent). Handy for logging.
108
+ def idempotency_key
109
+ @idempotency_key
110
+ end
111
+
112
+ # Digest of the request payload, used to reject reusing one key for a
113
+ # different request. Public override point — e.g. for raw-body APIs:
114
+ # def idempotency_fingerprint = Digest::SHA256.hexdigest(request.raw_post)
115
+ # Override it for multipart endpoints too: an uploaded file stringifies
116
+ # with its object id, so retried uploads never match and 422 — digest
117
+ # stable parts instead (e.g. params[:file]&.original_filename).
118
+ def idempotency_fingerprint
119
+ raw = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
120
+ filtered = raw.reject { |k, _| IGNORED_FINGERPRINT_KEYS.include?(k.to_s) }
121
+ Digest::SHA256.hexdigest(JSON.generate(idempotency_deep_sort(filtered)))
122
+ end
123
+
124
+ # Public override point for how a cached response is replayed.
125
+ def replay_idempotent_response(record)
126
+ return unless respond_to?(:response) && response
127
+
128
+ emit_idempotency_replayed_header(true)
129
+ options = { body: record["body"], status: record["status"] }
130
+ options[:content_type] = record["content_type"] if record["content_type"]
131
+ render(options)
132
+ end
133
+
134
+ # Single funnel for all error outcomes. Uses Respondable's render_error
135
+ # when available, otherwise the same inline envelope as Throttleable.
136
+ def idempotency_error_response(message:, status:, code:)
137
+ return unless respond_to?(:response) && response
138
+
139
+ return render_error(message: message, status: status, code: code) if respond_to?(:render_error)
140
+
141
+ render json: { success: false, error: { message: message, code: code } }, status: status
142
+ end
143
+
144
+ private
145
+
146
+ def idempotency_rule_for_action
147
+ action = respond_to?(:action_name) ? action_name.to_s : nil
148
+ return nil unless action
149
+
150
+ self.class.idempotency_rules.find { |rule| rule[:actions].include?(action) }
151
+ end
152
+
153
+ def idempotency_handle_missing_key(rule, &)
154
+ return yield unless rule[:required]
155
+
156
+ idempotency_error_response(message: "#{rule[:header]} header is required for this action.",
157
+ status: :bad_request, code: "idempotency_key_missing")
158
+ end
159
+
160
+ def valid_idempotency_key?(key)
161
+ # Control characters are rejected because the raw key is echoed in the
162
+ # X-Idempotency-Key response header — CR/LF would enable header
163
+ # injection on servers that don't validate header values (Rack 2).
164
+ !key.empty? && key.length <= MAX_KEY_LENGTH && !key.match?(/[[:cntrl:]]/)
165
+ end
166
+
167
+ def run_with_idempotency(rule, key, &)
168
+ store = idempotency_store!
169
+ cache_key = idempotency_cache_key(key)
170
+ fingerprint = idempotency_fingerprint
171
+ emit_idempotency_key_header(key)
172
+
173
+ claim = { "state" => "in_flight", "fingerprint" => fingerprint, "claimed_at" => Time.now.to_i }
174
+ if store.write(cache_key, claim, expires_in: rule[:lock_ttl], unless_exist: true)
175
+ idempotency_execute_and_store(store, cache_key, rule, fingerprint, &)
176
+ else
177
+ idempotency_resolve_existing(store, cache_key, rule, fingerprint)
178
+ end
179
+ end
180
+
181
+ def idempotency_execute_and_store(store, cache_key, rule, fingerprint)
182
+ emit_idempotency_replayed_header(false)
183
+ completed = false
184
+ begin
185
+ yield
186
+ completed = true
187
+ ensure
188
+ # Covers raise and throw alike, so a retry can re-execute.
189
+ store.delete(cache_key) unless completed
190
+ end
191
+
192
+ status = idempotency_response_status
193
+ return store.delete(cache_key) if status >= 500
194
+
195
+ record = { "state" => "done", "status" => status, "body" => idempotency_response_body,
196
+ "content_type" => idempotency_response_content_type, "fingerprint" => fingerprint }
197
+ store.write(cache_key, record, expires_in: rule[:ttl])
198
+ end
199
+
200
+ def idempotency_resolve_existing(store, cache_key, rule, fingerprint)
201
+ record = store.read(cache_key)
202
+
203
+ if record && record["fingerprint"] != fingerprint
204
+ return idempotency_error_response(message: "#{rule[:header]} was already used with a different request payload.",
205
+ status: :unprocessable_entity, code: "idempotency_key_reuse")
206
+ end
207
+
208
+ return replay_idempotent_response(record) if record && record["state"] == "done"
209
+
210
+ # In flight — or the claim expired between our failed write and this
211
+ # read (rare); answering 409 is the conservative, retry-safe choice.
212
+ idempotency_conflict_response(rule)
213
+ end
214
+
215
+ def idempotency_conflict_response(rule)
216
+ response.set_header("Retry-After", rule[:lock_ttl].to_s) if respond_to?(:response) && response
217
+ idempotency_error_response(message: "A request with this #{rule[:header]} is already in progress.",
218
+ status: :conflict, code: "idempotency_conflict")
219
+ end
220
+
221
+ def read_idempotency_header(rule)
222
+ return nil unless respond_to?(:request) && request.respond_to?(:headers) && request.headers
223
+
224
+ request.headers[rule[:header]]
225
+ end
226
+
227
+ def idempotency_cache_key(key)
228
+ # The user key is hashed so any validated key is safe in any backend
229
+ # (memcached limits key length and bans whitespace/control characters).
230
+ "idempotentable:#{idempotency_scope}:#{Digest::SHA256.hexdigest(key)}"
231
+ end
232
+
233
+ def idempotency_scope
234
+ controller = respond_to?(:controller_path) ? controller_path : self.class.name || "anonymous"
235
+ action = respond_to?(:action_name) ? action_name.to_s : ""
236
+ "#{controller}##{action}"
237
+ end
238
+
239
+ def idempotency_store!
240
+ store = self.class.idempotency_store
241
+ return store if store
242
+
243
+ raise ArgumentError,
244
+ "ConcernsOnRails::Controllers::Idempotentable: no store configured. " \
245
+ "Set `self.idempotency_store = Rails.cache` " \
246
+ "(must support #read, #write(expires_in:, unless_exist:) and #delete)."
247
+ end
248
+
249
+ def idempotency_response_status
250
+ return 200 unless respond_to?(:response) && response.respond_to?(:status)
251
+
252
+ response.status.to_i
253
+ end
254
+
255
+ def idempotency_response_body
256
+ return nil unless respond_to?(:response) && response.respond_to?(:body)
257
+
258
+ response.body
259
+ end
260
+
261
+ def idempotency_response_content_type
262
+ return nil unless respond_to?(:response) && response
263
+
264
+ # media_type (real Rails) excludes the charset; the harness only has content_type.
265
+ if response.respond_to?(:media_type) && response.media_type
266
+ response.media_type
267
+ elsif response.respond_to?(:content_type)
268
+ response.content_type
269
+ end
270
+ end
271
+
272
+ def emit_idempotency_key_header(key)
273
+ response.set_header("X-Idempotency-Key", key) if respond_to?(:response) && response
274
+ end
275
+
276
+ def emit_idempotency_replayed_header(replayed)
277
+ response.set_header("X-Idempotency-Replayed", replayed.to_s) if respond_to?(:response) && response
278
+ end
279
+
280
+ # Deterministic JSON regardless of param insertion order: hashes become
281
+ # sorted [key, value] pairs, arrays keep their (significant) order, and
282
+ # non-JSON-primitive leaves are stringified so nothing can raise.
283
+ def idempotency_deep_sort(value)
284
+ case value
285
+ when Hash then value.map { |k, v| [k.to_s, idempotency_deep_sort(v)] }.sort_by(&:first)
286
+ when Array then value.map { |v| idempotency_deep_sort(v) }
287
+ else idempotency_scalar(value)
288
+ end
289
+ end
290
+
291
+ def idempotency_scalar(value)
292
+ case value
293
+ when Float then value.finite? ? value : value.to_s # JSON.generate raises on NaN/Infinity
294
+ when nil, true, false, Integer, String then value
295
+ else value.to_s
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end
@@ -20,4 +20,5 @@ module ConcernsOnRails
20
20
  Sanitizable = Models::Sanitizable
21
21
  Maskable = Models::Maskable
22
22
  Monetizable = Models::Monetizable
23
+ Auditable = Models::Auditable
23
24
  end
@@ -0,0 +1,224 @@
1
+ require "active_support/concern"
2
+ require "bigdecimal"
3
+ require "json"
4
+
5
+ module ConcernsOnRails
6
+ module Models
7
+ # Lightweight change history ("paper_trail-lite") stored as JSON entries in
8
+ # a single text column on the same table — no extra tables, no versioning
9
+ # engine — so it works on any database, including SQLite.
10
+ #
11
+ # class Product < ApplicationRecord
12
+ # include ConcernsOnRails::Auditable
13
+ #
14
+ # auditable_by :price, :status # default column :audit_log
15
+ # # auditable_by :price, into: :history,
16
+ # # actor: -> { Current.user&.email }, # stamps "by"
17
+ # # max_entries: 50, # keep the newest 50
18
+ # # max_value_length: 120 # truncate long from/to strings
19
+ # end
20
+ #
21
+ # product.update!(price: 200)
22
+ # product.audit_trail
23
+ # # => [{"field"=>"price", "from"=>100, "to"=>200,
24
+ # # "at"=>"2026-06-10T12:34:56Z", "by"=>"admin@shop.com"}]
25
+ # product.last_change_for(:price) # newest entry for one field
26
+ # product.audited_changes_since(1.day.ago)
27
+ # product.clear_audit_trail! # wipe the column (skips callbacks)
28
+ #
29
+ # Notes:
30
+ # * One entry per changed field per save; creates record `"from" => nil`.
31
+ # "by" is omitted entirely when no actor is configured (or it returns nil).
32
+ # * Entries are appended in the same INSERT/UPDATE via before_save — no
33
+ # extra queries. Writes that skip callbacks (update_column(s), touch,
34
+ # increment!) are NOT audited.
35
+ # * Values are JSON-coerced: times → ISO8601 UTC strings, BigDecimal →
36
+ # plain numeric string, symbols → strings. With `max_value_length:`,
37
+ # String values longer than the limit are stored as the first N
38
+ # characters plus a trailing "…" (non-strings are never truncated).
39
+ # * A corrupt or non-array column decodes as [] and is overwritten on the
40
+ # next tracked save. Concurrent saves of one row are last-writer-wins.
41
+ # * New entries are built on the PERSISTED trail (so an aborted save
42
+ # can't duplicate entries on retry). Assigning the audit column by
43
+ # hand in the same save as a tracked change is therefore ignored —
44
+ # use clear_audit_trail! to reset it.
45
+ # * Reach for paper_trail/audited when you need reify/undo, who-dunnit
46
+ # queries across models, or association tracking.
47
+ module Auditable
48
+ extend ActiveSupport::Concern
49
+
50
+ LABEL = "ConcernsOnRails::Models::Auditable".freeze
51
+ DEFAULT_INTO = :audit_log
52
+ DEFAULT_MAX_ENTRIES = 200
53
+
54
+ included do
55
+ class_attribute :auditable_fields, instance_accessor: false, default: []
56
+ class_attribute :auditable_into, instance_accessor: false, default: DEFAULT_INTO
57
+ class_attribute :auditable_actor, instance_accessor: false, default: nil
58
+ class_attribute :auditable_max_entries, instance_accessor: false, default: DEFAULT_MAX_ENTRIES
59
+ class_attribute :auditable_max_value_length, instance_accessor: false, default: nil
60
+ end
61
+
62
+ module ClassMethods
63
+ include ConcernsOnRails::Support::ColumnGuard
64
+
65
+ # Configure the tracked fields and the audit column. See the module docs.
66
+ def auditable_by(*fields, into: DEFAULT_INTO, actor: nil, max_entries: DEFAULT_MAX_ENTRIES, max_value_length: nil)
67
+ fields = fields.flatten.map(&:to_sym).uniq
68
+ into = into.to_sym
69
+ validate_auditable!(fields, into: into, actor: actor, max_entries: max_entries, max_value_length: max_value_length)
70
+
71
+ self.auditable_fields = fields
72
+ self.auditable_into = into
73
+ self.auditable_actor = actor
74
+ self.auditable_max_entries = max_entries
75
+ self.auditable_max_value_length = max_value_length
76
+ ensure_columns!(LABEL, into, *fields)
77
+
78
+ before_save :auditable_capture_changes
79
+ end
80
+
81
+ private
82
+
83
+ def validate_auditable!(fields, into:, actor:, max_entries:, max_value_length:)
84
+ raise ArgumentError, "#{LABEL}: auditable_by requires at least one field" if fields.empty?
85
+ raise ArgumentError, "#{LABEL}: cannot track the audit column ':#{into}' itself" if fields.include?(into)
86
+ raise ArgumentError, "#{LABEL}: max_entries must be a positive Integer or nil" unless positive_integer_or_nil?(max_entries)
87
+ unless positive_integer_or_nil?(max_value_length)
88
+ raise ArgumentError, "#{LABEL}: max_value_length must be a positive Integer or nil"
89
+ end
90
+ raise ArgumentError, "#{LABEL}: actor must be callable (respond to #call)" unless actor.nil? || actor.respond_to?(:call)
91
+ end
92
+
93
+ def positive_integer_or_nil?(value)
94
+ value.nil? || (value.is_a?(Integer) && value.positive?)
95
+ end
96
+ end
97
+
98
+ # ---- instance methods ----
99
+
100
+ # Decoded audit entries, oldest first. [] for blank/corrupt columns.
101
+ def audit_trail
102
+ auditable_decode(self[self.class.auditable_into])
103
+ end
104
+
105
+ # The most recent entry recorded for `field`, or nil.
106
+ def last_change_for(field)
107
+ name = field.to_s
108
+ audit_trail.reverse_each.find { |entry| entry["field"] == name }
109
+ end
110
+
111
+ # Entries recorded at or after `time`, oldest first. Entries whose "at"
112
+ # is missing or unparseable are excluded.
113
+ def audited_changes_since(time)
114
+ audit_trail.select do |entry|
115
+ at = auditable_parse_time(entry["at"])
116
+ at && at >= time
117
+ end
118
+ end
119
+
120
+ # Wipe the trail with a single UPDATE. Deliberately uses update_column so
121
+ # clearing can never itself be captured or run other callbacks/validations.
122
+ def clear_audit_trail!
123
+ raise ArgumentError, "#{LABEL}: clear_audit_trail! cannot be called on a new record" if new_record?
124
+
125
+ update_column(self.class.auditable_into, nil)
126
+ end
127
+
128
+ private
129
+
130
+ # before_save hook — appends one entry per changed tracked field so the
131
+ # audit column rides the same INSERT/UPDATE statement.
132
+ def auditable_capture_changes
133
+ fields = self.class.auditable_fields
134
+ return if fields.blank?
135
+
136
+ pending = respond_to?(:changes_to_save) ? changes_to_save : changes
137
+ tracked = pending.slice(*fields.map(&:to_s))
138
+ return if tracked.empty?
139
+
140
+ entries = auditable_persisted_trail + auditable_build_entries(tracked)
141
+ max = self.class.auditable_max_entries
142
+ entries = entries.last(max) if max
143
+ self[self.class.auditable_into] = JSON.generate(entries)
144
+ end
145
+
146
+ # Base the new trail on the PERSISTED column value, not the in-memory
147
+ # attribute: an aborted save leaves the in-memory column holding the
148
+ # entry it appended, and reading it back on retry would duplicate the
149
+ # change. (attribute_in_database is Rails 5.1+; fall back for 5.0.)
150
+ def auditable_persisted_trail
151
+ into = self.class.auditable_into
152
+ raw = respond_to?(:attribute_in_database) ? attribute_in_database(into.to_s) : self[into]
153
+ auditable_decode(raw)
154
+ end
155
+
156
+ def auditable_build_entries(tracked)
157
+ at = Time.now.utc.iso8601
158
+ by = auditable_resolve_actor
159
+ tracked.map do |field, (from, to)|
160
+ entry = { "field" => field, "from" => auditable_entry_value(from), "to" => auditable_entry_value(to), "at" => at }
161
+ entry["by"] = by unless by.nil?
162
+ entry
163
+ end
164
+ end
165
+
166
+ def auditable_resolve_actor
167
+ actor = self.class.auditable_actor
168
+ return nil unless actor
169
+
170
+ auditable_json_value(instance_exec(&actor))
171
+ end
172
+
173
+ # from/to pipeline: JSON coercion, then opt-in truncation.
174
+ def auditable_entry_value(value)
175
+ auditable_truncate(auditable_json_value(value))
176
+ end
177
+
178
+ # Keep the first max_value_length characters and mark the cut with "…".
179
+ # Only String values are truncated — numbers, booleans, arrays pass through.
180
+ def auditable_truncate(value)
181
+ limit = self.class.auditable_max_value_length
182
+ return value unless limit && value.is_a?(String) && value.length > limit
183
+
184
+ "#{value[0, limit]}…"
185
+ end
186
+
187
+ # Coerce a Ruby value to a JSON-safe primitive. Times → ISO8601 UTC,
188
+ # BigDecimal → plain numeric string (precision-safe), Symbol → String.
189
+ # TimeWithZone is caught by `when Time` (it masquerades via #is_a?).
190
+ def auditable_json_value(value)
191
+ case value
192
+ when nil, true, false, Integer, String then value
193
+ when Float then auditable_float_value(value)
194
+ when BigDecimal then value.to_s("F")
195
+ when Time, DateTime then value.to_time.utc.iso8601
196
+ when Date then value.iso8601
197
+ when Symbol then value.to_s
198
+ else value.as_json
199
+ end
200
+ end
201
+
202
+ # JSON.generate raises on non-finite floats — store NaN/Infinity as strings.
203
+ def auditable_float_value(value)
204
+ value.finite? ? value : value.to_s
205
+ end
206
+
207
+ def auditable_parse_time(raw)
208
+ Time.iso8601(raw.to_s)
209
+ rescue ArgumentError, TypeError
210
+ nil
211
+ end
212
+
213
+ # Tolerant decode: blank, invalid JSON or non-array payloads become [].
214
+ def auditable_decode(raw)
215
+ return [] if raw.nil? || raw.to_s.strip.empty?
216
+
217
+ parsed = JSON.parse(raw)
218
+ parsed.is_a?(Array) ? parsed.grep(Hash) : []
219
+ rescue JSON::ParserError
220
+ []
221
+ end
222
+ end
223
+ end
224
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.15.0".freeze
2
+ VERSION = "1.16.0".freeze
3
3
  end
@@ -35,6 +35,7 @@ require "concerns_on_rails/models/taggable"
35
35
  require "concerns_on_rails/models/sanitizable"
36
36
  require "concerns_on_rails/models/maskable"
37
37
  require "concerns_on_rails/models/monetizable"
38
+ require "concerns_on_rails/models/auditable"
38
39
 
39
40
  # Controller concerns
40
41
  require "concerns_on_rails/controllers/paginatable"
@@ -48,6 +49,7 @@ require "concerns_on_rails/controllers/localizable"
48
49
  require "concerns_on_rails/controllers/authorizable"
49
50
  require "concerns_on_rails/controllers/throttleable"
50
51
  require "concerns_on_rails/controllers/timezoneable"
52
+ require "concerns_on_rails/controllers/idempotentable"
51
53
 
52
54
  # Backwards compatibility (top-level aliases for pre-1.6 module paths)
53
55
  require "concerns_on_rails/legacy_aliases"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.15.0
4
+ version: 1.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
@@ -73,6 +73,7 @@ files:
73
73
  - lib/concerns_on_rails/controllers/authorizable.rb
74
74
  - lib/concerns_on_rails/controllers/error_handleable.rb
75
75
  - lib/concerns_on_rails/controllers/filterable.rb
76
+ - lib/concerns_on_rails/controllers/idempotentable.rb
76
77
  - lib/concerns_on_rails/controllers/includable.rb
77
78
  - lib/concerns_on_rails/controllers/localizable.rb
78
79
  - lib/concerns_on_rails/controllers/paginatable.rb
@@ -84,6 +85,7 @@ files:
84
85
  - lib/concerns_on_rails/legacy_aliases.rb
85
86
  - lib/concerns_on_rails/models/activatable.rb
86
87
  - lib/concerns_on_rails/models/addressable.rb
88
+ - lib/concerns_on_rails/models/auditable.rb
87
89
  - lib/concerns_on_rails/models/expirable.rb
88
90
  - lib/concerns_on_rails/models/hashable.rb
89
91
  - lib/concerns_on_rails/models/maskable.rb