standard_ledger 0.3.0 → 0.5.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: 276066f9976675bcd6ed7220d0df2d72518c355eab43036807cf53c1c1da7e00
4
- data.tar.gz: 01de25809973bb72ed2414f32f5c6c9398f45f2d44fed75bfdb1743c9beaaedc
3
+ metadata.gz: 89af058a7d94051bc75f8e853d1be397270b52bc5a02020dfdd41484c6cacda5
4
+ data.tar.gz: 50eef558981621f3561c42095d294f87f09d7ed18e8fc5a5919c0801262f257b
5
5
  SHA512:
6
- metadata.gz: bc772c009801e0bf6ab2e3568e95aeff7305413fce076742d89dfb62388cada99a168659fc0745807ca661c31bd30cc33529b105c4f7bd163a91090d3b5603ab
7
- data.tar.gz: 1002bacfe4f93410a2d06ffda6dec585328c0c248d518f3e0e12148a9bd5effb451f02dbb1967569b3e2d30b3d8c71bcc4abba53efa7e41d218f451ee21d659c
6
+ metadata.gz: 16d8c529a99909adb4739feb0ea76e996ebd709c044bc56dd2722bc041f4c17670d2bb383a48072df9a686bc02b1c2a49add8206618ddeb7fb86dd2b45e61c9f
7
+ data.tar.gz: 5f152b064c69e8da3f7e2767c4b3914da197599ed64ecc8e512be6c34e334af25337f83fd68080c7811a3f4772df647d5799ae635cb0116409286455bd86bf0e
data/CHANGELOG.md CHANGED
@@ -6,7 +6,81 @@ project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- Nothing yet.
9
+ ## [0.5.0] - 2026-09-22
10
+
11
+ ### Changed
12
+ - `required_ruby_version` raised from `>= 3.4` to `>= 4.0`, and the repo's
13
+ `.ruby-version` moved from 3.4.4 to 4.0.1 to match. The 3.4 floor was never
14
+ exercised — CI has only ever run the 4.0.x matrix — and every consumer runs
15
+ 4.0.x. This aligns the gem with the rest of the `standard_*` family.
16
+
17
+ ### Documentation
18
+ - New README **Events** section documenting all five lifecycle events
19
+ (`entry.created`, `projection.applied`, `projection.failed`,
20
+ `projection.refreshed`, `projection.rebuilt`), their payloads, and the four
21
+ places a payload shape differs from the obvious one: `:sql` mode sends
22
+ `target: nil`, `:matview` events carry `view:`/`concurrently:` instead of
23
+ `entry:`/`target:`, `projection.rebuilt` carries `entry_class:` rather than
24
+ `entry:`, and input errors (matview name validation,
25
+ `RefreshInsideTransaction`) propagate without firing `projection.failed`.
26
+ Also records that subscriber exceptions are swallowed by design.
27
+ - README installation now shows the RubyGems pin. It still said "private during
28
+ incubation, pin from git"; the gem has been on RubyGems since 2026-05-07 and
29
+ the last git-pinned consumer was converted in 2026-07.
30
+ - `CLAUDE.md` consumer list corrected: it listed two consumers and claimed
31
+ git+tag consumption. There are four (`fundbright-web`, `luminality-web`,
32
+ `sidekick-web`, `jumpdrive-web`), all plain rubygems.
33
+
34
+ ## [0.4.0] - 2026-05-07
35
+
36
+ ### Added
37
+ - New `:manual` projection mode. Records the projection contract
38
+ (target + projector class) without installing any callback —
39
+ intended for AASM/state-machine entries whose interesting lifecycle
40
+ event is a transition rather than `after_create`. Hosts invoke the
41
+ projector explicitly from operation code; the gem keeps the
42
+ contract introspectable (`standard_ledger_projections`) and
43
+ log-replayable via `StandardLedger.rebuild!`. Requires
44
+ `via: ProjectorClass`; rejects blocks, locks, and `permissive:`.
45
+ - New `allow_destroy:` keyword on `ledger_entry`. When `true`, an
46
+ immutable entry permits `destroy` (including `dependent: :destroy`
47
+ cascades from a parent record) while still blocking `save`/`update`.
48
+ The default is `false` — preserves the strict journal contract.
49
+ Use this when an owning record's destroy cascade needs to reap
50
+ events for sandbox tear-down or GDPR erasure.
51
+ - New `counters:` shortcut for `:inline` projections. A
52
+ `kind => column` Hash that synthesises one
53
+ `on(kind) { |t, _| t.class.increment_counter(col, t.id) }` per
54
+ entry. Direct UPDATE (the class-method form) is intentional — it
55
+ invalidates the SQL query cache for the target table, keeping
56
+ multiple sibling-entry creates inside a single transaction (e.g.
57
+ via `accepts_nested_attributes_for`) from losing updates against
58
+ stale cached reads. Block form remains available for non-counter
59
+ projections.
60
+ - New `rebuild_sql:` keyword on `:trigger` mode. Equivalent to the
61
+ block-DSL `rebuild_sql "..."` clause, callable without a block.
62
+ - Partial unique indexes are now accepted by the idempotency-index
63
+ validator when their predicate is the canonical
64
+ `<idempotency_key> IS NOT NULL` shape. Other predicates still raise
65
+ `MissingIdempotencyIndex` with a clearer error message.
66
+ - New `StandardLedger::RefreshInsideTransaction` error. Raised when
67
+ `StandardLedger.refresh!(view, concurrently: true)` is called
68
+ inside an open transaction — Postgres rejects
69
+ `REFRESH MATERIALIZED VIEW CONCURRENTLY` inside transaction blocks,
70
+ and the gem now catches this at the boundary instead of letting
71
+ `PG::ActiveSqlTransaction` escape mid-call. The non-concurrent
72
+ form is still permitted by Postgres inside transactions and is
73
+ unaffected. No SQL is issued and no `.refreshed`/`.failed` event
74
+ fires when the guard rejects.
75
+ - `docs/MIGRATION_GUIDE.md` covering the five real-world adoption
76
+ paths (counter caches, custom inline logic, bespoke jobs, existing
77
+ Postgres triggers, AASM state machines) and the cascade-delete /
78
+ refresh-in-transaction edge cases.
79
+
80
+ ### Documentation
81
+ - `ledger_entry`'s YARD now notes that a single-symbol `scope:` is
82
+ normalised to a flat array on the stored config; assertions in
83
+ host specs should compare against `[:foo]`, not `:foo`.
10
84
 
11
85
  ## [0.3.0] - 2026-05-05
12
86
 
@@ -372,7 +446,9 @@ roadmap.
372
446
  and `:trigger` (host-owned, gem records rebuild SQL).
373
447
  - `standard_ledger:doctor` rake task (verifies trigger presence, etc.).
374
448
 
375
- [Unreleased]: https://github.com/rarebit-one/standard_ledger/compare/v0.3.0...HEAD
449
+ [Unreleased]: https://github.com/rarebit-one/standard_ledger/compare/v0.5.0...HEAD
450
+ [0.5.0]: https://github.com/rarebit-one/standard_ledger/compare/v0.4.0...v0.5.0
451
+ [0.4.0]: https://github.com/rarebit-one/standard_ledger/compare/v0.3.0...v0.4.0
376
452
  [0.3.0]: https://github.com/rarebit-one/standard_ledger/compare/v0.2.0...v0.3.0
377
453
  [0.2.0]: https://github.com/rarebit-one/standard_ledger/compare/v0.1.0...v0.2.0
378
454
  [0.1.0]: https://github.com/rarebit-one/standard_ledger/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Rarebit One
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md CHANGED
@@ -283,12 +283,16 @@ Five projection modes — pick per declaration:
283
283
 
284
284
  ## Installation
285
285
 
286
- The gem is private during incubation. Pin from git:
286
+ The gem is published on RubyGems:
287
287
 
288
288
  ```ruby
289
- gem "standard_ledger", git: "https://github.com/rarebit-one/standard_ledger", ref: "<sha>"
289
+ gem "standard_ledger", "~> 0.4"
290
290
  ```
291
291
 
292
+ (It was git-pinned during incubation. Don't reintroduce a `git:` reference —
293
+ it makes a bare `bundle install` a prerequisite for every other command in a
294
+ fresh checkout, blocking `rubocop`/`rspec` until it has been run.)
295
+
292
296
  Then run the install generator to drop a configured initializer in place:
293
297
 
294
298
  ```bash
@@ -317,6 +321,57 @@ StandardLedger.configure do |c|
317
321
  end
318
322
  ```
319
323
 
324
+ ## Events
325
+
326
+ The gem emits five lifecycle events. On Rails 8.1+ they go through
327
+ `Rails.event.notify(name, **payload)`; on older Rails (or any host without the
328
+ structured reporter) they fall back to
329
+ `ActiveSupport::Notifications.instrument(name, payload)`. The backend is
330
+ detected per call, not cached at load — the gem is required before Rails has
331
+ finished booting.
332
+
333
+ Every name is prefixed with `Config#notification_namespace` (default
334
+ `standard_ledger`), so a host that renames the namespace renames all five.
335
+
336
+ | Event | Fired when | Payload |
337
+ |---|---|---|
338
+ | `<prefix>.entry.created` | after the entry's transaction commits | `entry:`, `kind:`, `targets:` (a `{ name => target }` hash) |
339
+ | `<prefix>.projection.applied` | a projection wrote successfully | `entry:`, `target:`, `projection:`, `mode:`, `duration_ms:` — plus `attempt:` in `:async` mode |
340
+ | `<prefix>.projection.failed` | a projection raised | the `applied` payload plus `error:` (the exception) |
341
+ | `<prefix>.projection.refreshed` | a matview refresh succeeded | `view:`, `concurrently:`, `duration_ms:` |
342
+ | `<prefix>.projection.rebuilt` | `StandardLedger.rebuild!` finished one target | `entry_class:`, `target:`, `projection:`, `mode:` |
343
+
344
+ Four payload shapes are worth knowing before you write a subscriber that
345
+ assumes a key is always present:
346
+
347
+ - **`:sql` mode sends `target: nil`.** The recompute statement is bound by
348
+ `:target_id` and never loads the record, so there is no object to hand you.
349
+ - **`:matview` events carry `view:`/`concurrently:` instead of
350
+ `entry:`/`target:`.** A refresh is view-wide — Postgres has no partial-refresh
351
+ primitive — so no single entry or target caused it. The matview variant of
352
+ `projection.failed` also carries `mode: :matview`, while
353
+ `projection.refreshed` carries no `mode:` at all.
354
+ - **`projection.rebuilt` carries `entry_class:`, not `entry:`.** Rebuild is
355
+ log replay across an entire class; there is no originating entry.
356
+ - **`projection.failed` is not fired for input errors.** An `ArgumentError`
357
+ from the matview name validator, or `RefreshInsideTransaction` from the
358
+ boundary check, propagates without an event — the SQL was never issued, so
359
+ nothing failed to project.
360
+
361
+ **Subscriber exceptions are swallowed** (warned to stderr, not re-raised).
362
+ Ledger observability must never take down a host's request path: by emit time
363
+ the projection has already either succeeded or been rolled back, so there is
364
+ nothing a raising subscriber could usefully abort. Don't put work in a
365
+ subscriber that you need to have happened.
366
+
367
+ Retries: `:async` projections are capped by `Config#default_async_retries`
368
+ (default 3), and both `applied` and `failed` carry `attempt:` so subscribers
369
+ can tell first-try success from retry success.
370
+
371
+ `standard_audit` consumers can subscribe to `entry.created` to write an audit
372
+ row; the gem itself never calls into audit. That coupling is deliberately the
373
+ host's to opt into — see "Relationship to standard_audit" below.
374
+
320
375
  ## Testing
321
376
 
322
377
  The gem ships an opt-in RSpec support file. Hosts add this to their
@@ -382,4 +437,4 @@ Neither subsumes the other.
382
437
 
383
438
  ## License
384
439
 
385
- MIT. See [MIT-LICENSE](MIT-LICENSE).
440
+ MIT. See [LICENSE](LICENSE).
@@ -35,15 +35,29 @@ module StandardLedger
35
35
  # guards against duplicate inserts. `nil` means the entry is not
36
36
  # idempotent — explicitly opt-in to that.
37
37
  # @param scope [Symbol, Array<Symbol>, nil] additional columns the
38
- # idempotency index is scoped by (e.g. `:organisation_id`).
39
- # @param immutable [Boolean] when true (default), `save`/`update`/
40
- # `destroy` raise after the row is persisted.
41
- def ledger_entry(kind: :kind, idempotency_key: nil, scope: nil, immutable: true)
38
+ # idempotency index is scoped by (e.g. `:organisation_id`). Always
39
+ # normalised to a flat array on the stored config so downstream
40
+ # reads don't need to handle both shapes — assertions in host specs
41
+ # should compare against `[:foo]`, not `:foo`.
42
+ # @param immutable [Boolean] when true (default), `save`/`update`
43
+ # raise after the row is persisted. Also blocks `destroy` unless
44
+ # `allow_destroy: true` is set.
45
+ # @param allow_destroy [Boolean] when true, `destroy` (including
46
+ # `dependent: :destroy` cascades from a parent record) is permitted
47
+ # even on `immutable: true` entries. Use this when an owning record
48
+ # declares `has_many :events, dependent: :destroy` and you want the
49
+ # cascade to work for cleanup paths (sandbox tear-down, GDPR
50
+ # erasure, etc.) while still blocking app-code mutations to
51
+ # persisted entries. Defaults to `false` — keeping the strict
52
+ # journal contract.
53
+ def ledger_entry(kind: :kind, idempotency_key: nil, scope: nil,
54
+ immutable: true, allow_destroy: false)
42
55
  self.standard_ledger_entry_config = {
43
56
  kind: kind,
44
57
  idempotency_key: idempotency_key,
45
58
  scope: Array(scope).compact,
46
- immutable: immutable
59
+ immutable: immutable,
60
+ allow_destroy: allow_destroy
47
61
  }
48
62
  self.standard_ledger_idempotency_index_validated = false
49
63
  end
@@ -97,14 +111,28 @@ module StandardLedger
97
111
  indexes = connection.indexes(table_name)
98
112
 
99
113
  match = indexes.any? do |index|
100
- index.unique && index.columns.map(&:to_s).to_set == required
114
+ next false unless index.unique
115
+ next false unless index.columns.map(&:to_s).to_set == required
116
+
117
+ # Full-table unique indexes are always valid. Partial indexes are
118
+ # accepted only when the predicate is the canonical
119
+ # `<idempotency_key> IS NOT NULL` shape — that's the common
120
+ # real-world pattern (e.g. an event table whose serial number is
121
+ # optional but unique-per-scope when present), and it preserves
122
+ # the gem's idempotency contract: rows with a non-null key are
123
+ # deduped; rows without one are explicitly opting out.
124
+ standard_ledger_index_predicate_acceptable?(index, config[:idempotency_key])
101
125
  end
102
126
 
103
127
  unless match
104
128
  raise StandardLedger::MissingIdempotencyIndex,
105
129
  "#{name} declares idempotency_key: #{config[:idempotency_key].inspect} " \
106
130
  "with scope: #{config[:scope].inspect} but no matching unique index " \
107
- "covers exactly #{required.to_a.sort.inspect} on `#{table_name}`."
131
+ "covers exactly #{required.to_a.sort.inspect} on `#{table_name}`. " \
132
+ "If a matching partial index exists, its WHERE predicate must be " \
133
+ "`#{config[:idempotency_key]} IS NOT NULL` (other predicates aren't " \
134
+ "automatically validated — opt out of the check by setting " \
135
+ "idempotency_key: nil and enforcing uniqueness another way)."
108
136
  end
109
137
 
110
138
  self.standard_ledger_idempotency_index_validated = true
@@ -112,6 +140,29 @@ module StandardLedger
112
140
 
113
141
  private
114
142
 
143
+ # Match a partial-index predicate of the form `<col> IS NOT NULL`
144
+ # (with optional whitespace and optional table/schema qualification
145
+ # on the column reference). Full-table indexes (no predicate) always
146
+ # qualify. This is conservative: predicates outside this shape can
147
+ # still be perfectly valid for the host's idempotency intent, but
148
+ # we'd need a real SQL parser to decide that — better to raise and
149
+ # let the host either restructure their index or opt out via
150
+ # `idempotency_key: nil`.
151
+ def standard_ledger_index_predicate_acceptable?(index, idempotency_key)
152
+ predicate = index.where
153
+ return true if predicate.nil? || predicate.to_s.strip.empty?
154
+
155
+ col = idempotency_key.to_s
156
+ # Postgres wraps the index predicate in parentheses when it returns
157
+ # it via pg_indexes (e.g. `(idempotency_key IS NOT NULL)`) — strip
158
+ # those along with the per-adapter quoting characters before
159
+ # matching. SQLite returns the raw expression, so the same strip is
160
+ # a no-op there. The regex tolerates whitespace around the column
161
+ # and operator and accepts an optional table-qualifier prefix.
162
+ normalised = predicate.to_s.gsub(/["`\[\]()]/, "").strip
163
+ normalised.match?(/\A([\w]+\.)?#{Regexp.escape(col)}\s+IS\s+NOT\s+NULL\z/i)
164
+ end
165
+
115
166
  def find_existing_standard_ledger_entry(attributes)
116
167
  return nil if attributes.nil?
117
168
 
@@ -165,9 +216,11 @@ module StandardLedger
165
216
  # plain Ruby classes that include Entry for testing the DSL surface
166
217
  # get the macro registration without the callback. AR's `readonly?`
167
218
  # path covers save/update on persisted rows; this catch-all stops
168
- # `destroy` for the AR case.
219
+ # `destroy` for the AR case unless the entry opts out via
220
+ # `allow_destroy: true` (typically because an owning record's
221
+ # `dependent: :destroy` cascade needs to reap them on cleanup).
169
222
  if respond_to?(:before_destroy)
170
- before_destroy :standard_ledger_raise_readonly, if: :standard_ledger_immutable?
223
+ before_destroy :standard_ledger_raise_readonly, if: :standard_ledger_destroy_blocked?
171
224
  end
172
225
 
173
226
  # Emit `<namespace>.entry.created` after the row is durably committed
@@ -187,16 +240,40 @@ module StandardLedger
187
240
  !!@_standard_ledger_idempotent
188
241
  end
189
242
 
190
- # AR consults `readonly?` from `save`/`update` paths; raising
243
+ # AR consults `readonly?` from `save`/`update`/`destroy` paths; raising
191
244
  # ReadOnlyRecord here matches the ActiveRecord contract for persisted
192
245
  # immutable rows. New, unpersisted instances stay writable so the
193
246
  # initial INSERT can land.
247
+ #
248
+ # When `allow_destroy: true` is set, `#destroy` toggles
249
+ # `@_standard_ledger_destroying` so `readonly?` returns false for the
250
+ # duration of the destroy call (and the duration of any cascade
251
+ # destroys that fire from its `dependent: :destroy` associations).
252
+ # The save/update path is unaffected — those still raise on
253
+ # persisted rows.
194
254
  def readonly?
195
255
  return super unless standard_ledger_immutable?
256
+ return false if @_standard_ledger_destroying
196
257
 
197
258
  !new_record?
198
259
  end
199
260
 
261
+ # Wrap `destroy` so it can bypass the `readonly?` guard when the
262
+ # entry has opted in via `allow_destroy: true`. This applies to
263
+ # `destroy`, `destroy!`, and `dependent: :destroy` cascades from a
264
+ # parent record (all routes call through `#destroy`).
265
+ def destroy
266
+ return super unless self.class.respond_to?(:standard_ledger_entry_config)
267
+
268
+ config = self.class.standard_ledger_entry_config
269
+ return super if config.nil? || !config[:immutable] || !config[:allow_destroy]
270
+
271
+ @_standard_ledger_destroying = true
272
+ super
273
+ ensure
274
+ @_standard_ledger_destroying = false
275
+ end
276
+
200
277
  # Returns the entry's belongs_to targets keyed by association name.
201
278
  # Used by the `entry.created` notification payload and by
202
279
  # `StandardLedger.post`'s telemetry. Skips polymorphic and missing
@@ -232,6 +309,18 @@ module StandardLedger
232
309
  !config.nil? && config[:immutable]
233
310
  end
234
311
 
312
+ # Destroys are blocked when the entry is `immutable: true` AND the user
313
+ # has not opted out via `allow_destroy: true`. Split out so the
314
+ # before_destroy guard can be conditional independently of the
315
+ # save/update `readonly?` path.
316
+ def standard_ledger_destroy_blocked?
317
+ config = self.class.standard_ledger_entry_config
318
+ return false if config.nil?
319
+ return false unless config[:immutable]
320
+
321
+ !config[:allow_destroy]
322
+ end
323
+
235
324
  def standard_ledger_raise_readonly
236
325
  raise ActiveRecord::ReadOnlyRecord
237
326
  end
@@ -30,4 +30,14 @@ module StandardLedger
30
30
  super("Enqueued #{enqueued.size} projections; #{failed.size} failed to enqueue")
31
31
  end
32
32
  end
33
+
34
+ # Raised when `StandardLedger.refresh!(view, concurrently: true)` is called
35
+ # inside an open transaction. PostgreSQL rejects
36
+ # `REFRESH MATERIALIZED VIEW CONCURRENTLY` inside transaction blocks; the
37
+ # gem catches this at the boundary so the failure is a clear,
38
+ # gem-attributable error instead of a raw `PG::ActiveSqlTransaction`.
39
+ # Callers wanting read-your-write semantics inside an operation should
40
+ # wrap the call in `connection.add_transaction_record { ... }` to defer
41
+ # to after-commit, or move the refresh outside the transaction block.
42
+ class RefreshInsideTransaction < Error; end
33
43
  end
@@ -63,6 +63,7 @@ module StandardLedger
63
63
  # the SQL — re-raised after the `failed` event fires.
64
64
  def self.refresh!(view_name, concurrently:)
65
65
  validate_view_name!(view_name)
66
+ check_transaction_state!(view_name, concurrently: concurrently)
66
67
 
67
68
  prefix = StandardLedger.config.notification_namespace
68
69
  sql = build_refresh_sql(view_name, concurrently: concurrently)
@@ -76,9 +77,10 @@ module StandardLedger
76
77
  view: view_name.to_s, concurrently: concurrently, duration_ms: duration_ms
77
78
  )
78
79
  rescue StandardError => e
79
- # ArgumentError from the validator should propagate without firing
80
- # the failed notification the SQL was never issued.
81
- raise if e.is_a?(ArgumentError)
80
+ # ArgumentError from the validator and RefreshInsideTransaction from
81
+ # the boundary check should propagate without firing the failed
82
+ # notification — the SQL was never issued.
83
+ raise if e.is_a?(ArgumentError) || e.is_a?(StandardLedger::RefreshInsideTransaction)
82
84
 
83
85
  StandardLedger::EventEmitter.emit(
84
86
  "#{prefix}.projection.failed",
@@ -95,13 +97,42 @@ module StandardLedger
95
97
  # injection isn't possible even when a host carelessly pipes a config
96
98
  # value into the call.
97
99
  def self.validate_view_name!(view_name)
98
- return if view_name.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_.]*\z/)
100
+ # Bare identifier OR exactly one schema-qualified `schema.view` part.
101
+ # The previous shape `\A[a-zA-Z_][a-zA-Z0-9_.]*\z` allowed trailing
102
+ # dots (`reporting.`) and unlimited qualification (`a.b.c.d`); both
103
+ # would round-trip to a Postgres syntax error at `connection.execute`
104
+ # time rather than the gem boundary.
105
+ return if view_name.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?\z/)
99
106
 
100
107
  raise ArgumentError,
101
108
  "view_name must be a valid SQL identifier; got #{view_name.inspect}"
102
109
  end
103
110
  private_class_method :validate_view_name!
104
111
 
112
+ # Reject `refresh!` calls issued from inside an open transaction when
113
+ # `concurrently: true`. Postgres rejects
114
+ # `REFRESH MATERIALIZED VIEW CONCURRENTLY` inside a transaction block
115
+ # (and would otherwise raise `PG::ActiveSqlTransaction` mid-call); we
116
+ # catch it at the gem boundary so the failure is attributable.
117
+ #
118
+ # The non-concurrent (`concurrently: false`) form *is* permitted inside
119
+ # a transaction by Postgres, so we only guard the concurrent path.
120
+ #
121
+ # Use `connection.add_transaction_record { … }` to defer to after-commit
122
+ # if you need read-your-write semantics from inside a transactional
123
+ # operation; otherwise, move the refresh outside the transaction.
124
+ def self.check_transaction_state!(view_name, concurrently:)
125
+ return unless concurrently
126
+ return unless ActiveRecord::Base.connection.transaction_open?
127
+
128
+ raise StandardLedger::RefreshInsideTransaction,
129
+ "StandardLedger.refresh!(#{view_name.inspect}) cannot run inside a transaction with " \
130
+ "concurrently: true — Postgres rejects `REFRESH MATERIALIZED VIEW CONCURRENTLY` inside " \
131
+ "transaction blocks. Move the call outside the transaction, or defer it via " \
132
+ "`connection.add_transaction_record { ... }` for after-commit execution."
133
+ end
134
+ private_class_method :check_transaction_state!
135
+
105
136
  def self.build_refresh_sql(view_name, concurrently:)
106
137
  if concurrently
107
138
  "REFRESH MATERIALIZED VIEW CONCURRENTLY #{view_name}"
@@ -70,9 +70,120 @@ module StandardLedger
70
70
  # @yield optional block-DSL form: register per-kind handlers via
71
71
  # `on(:kind) { |target, entry| ... }`. Not allowed for `mode: :matview`.
72
72
  # @return [Definition] the registered projection.
73
- def projects_onto(target_association, mode:, via: nil, if: nil, lock: nil, permissive: false, view: nil, refresh: nil, trigger_name: nil, **options, &block)
73
+ def projects_onto(target_association, mode:, via: nil, if: nil, lock: nil, permissive: false,
74
+ view: nil, refresh: nil, trigger_name: nil, counters: nil,
75
+ rebuild_sql: nil, **options, &block)
74
76
  guard = binding.local_variable_get(:if) # `if:` is a reserved keyword
75
77
 
78
+ # `counters:` is sugar for the most common :inline shape — a hash
79
+ # mapping `kind => column` that synthesises one
80
+ # `on(kind) { |t, _| t.class.increment_counter(col, t.id) }` per
81
+ # entry. Direct UPDATE (the class-method form) is intentional: it
82
+ # invalidates the SQL query cache for the target table on each
83
+ # call, which keeps multiple sibling-entry creates in a single
84
+ # transaction (e.g. via `accepts_nested_attributes_for`) from
85
+ # losing updates against stale cached reads. Block form remains
86
+ # available for non-counter projections.
87
+ if counters
88
+ if mode != :inline
89
+ raise ArgumentError,
90
+ "projects_onto :#{target_association} got `counters:` with mode: #{mode.inspect}; " \
91
+ "the counters shortcut is :inline-only — counter caches don't fit the async/sql/" \
92
+ "trigger/matview contracts"
93
+ end
94
+ if block || via
95
+ raise ArgumentError,
96
+ "projects_onto :#{target_association} got `counters:` together with a block or `via:`; " \
97
+ "the counters shortcut synthesises handlers automatically — use one form or the other"
98
+ end
99
+ unless counters.is_a?(Hash) && counters.all? { |k, v| k.is_a?(Symbol) && v.is_a?(Symbol) }
100
+ raise ArgumentError,
101
+ "projects_onto :#{target_association} `counters:` must be a Hash of Symbol kind => Symbol column"
102
+ end
103
+
104
+ block = ->(*) {
105
+ counters.each do |kind, column|
106
+ on(kind) { |target, _| target.class.increment_counter(column, target.id) }
107
+ end
108
+ }
109
+ end
110
+
111
+ # `rebuild_sql:` keyword form for `:trigger` mode — equivalent to
112
+ # the legacy block-DSL `rebuild_sql "..."` clause but doesn't
113
+ # require a block. The block-DSL form is still supported.
114
+ if rebuild_sql && mode == :trigger
115
+ if block
116
+ raise ArgumentError,
117
+ "projects_onto :#{target_association} got both `rebuild_sql:` and a block — " \
118
+ "use one form or the other"
119
+ end
120
+ # Capture the parameter value into a local before building the
121
+ # block: when the synthesised block is `instance_eval`'d on
122
+ # `TriggerDsl`, the bare name `rebuild_sql` resolves to
123
+ # `TriggerDsl#rebuild_sql` (the writer), not the keyword
124
+ # parameter we want to pass in.
125
+ rebuild_sql_value = rebuild_sql
126
+ block = ->(*) { rebuild_sql(rebuild_sql_value) }
127
+ end
128
+
129
+ if mode == :manual
130
+ # `:manual` records the projection contract (target + projector
131
+ # class) without installing any callback. Use this when the
132
+ # entry's interesting lifecycle event is not the create itself
133
+ # — typically an AASM/state-machine model where the projection
134
+ # should fire on a transition (e.g. `validate_disbursement!`)
135
+ # rather than `after_create`. Hosts invoke the projector
136
+ # explicitly from operation code; the gem's role is to make
137
+ # the contract introspectable (via `standard_ledger_projections`)
138
+ # and to give `StandardLedger.rebuild!` a class handle for log
139
+ # replay.
140
+ if block
141
+ raise ArgumentError,
142
+ "projects_onto :#{target_association} mode: :manual does not accept a block — " \
143
+ "the entry's lifecycle is owned by the host, so per-kind handlers can't fire " \
144
+ "automatically. Use `via: ProjectorClass` and invoke the projector explicitly " \
145
+ "from the operation that drives the lifecycle event."
146
+ end
147
+
148
+ if via.nil?
149
+ raise ArgumentError,
150
+ "projects_onto :#{target_association} mode: :manual requires `via: ProjectorClass` " \
151
+ "whose `apply(target, entry)` is invoked explicitly by host code on the lifecycle " \
152
+ "event the gem cannot observe (e.g. an AASM transition)."
153
+ end
154
+
155
+ unless lock.nil?
156
+ raise ArgumentError,
157
+ "projects_onto :#{target_association} got `lock:` with mode: :manual; " \
158
+ "the host owns the dispatch boundary and is responsible for any locking it needs."
159
+ end
160
+
161
+ if permissive
162
+ raise ArgumentError,
163
+ "projects_onto :#{target_association} got `permissive: true` with mode: :manual; " \
164
+ "`permissive:` is only meaningful with the block form."
165
+ end
166
+
167
+ definition = Definition.new(
168
+ target_association: target_association,
169
+ mode: mode,
170
+ projector_class: via,
171
+ handlers: {},
172
+ guard: guard,
173
+ lock: lock,
174
+ permissive: permissive,
175
+ recompute_sql: nil,
176
+ trigger_name: nil,
177
+ view: nil,
178
+ refresh_options: nil,
179
+ options: options
180
+ )
181
+
182
+ self.standard_ledger_projections = standard_ledger_projections + [ definition ]
183
+ # No `install_mode_callbacks_for` — the host owns the dispatch.
184
+ return definition
185
+ end
186
+
76
187
  if mode == :async
77
188
  if block
78
189
  raise ArgumentError,
@@ -1,3 +1,3 @@
1
1
  module StandardLedger
2
- VERSION = "0.3.0"
2
+ VERSION = "0.5.0"
3
3
  end
@@ -443,6 +443,7 @@ module StandardLedger
443
443
  def validate_rebuildable_mode!(entry_class, definition)
444
444
  return if definition.mode == :inline
445
445
  return if definition.mode == :async
446
+ return if definition.mode == :manual
446
447
  return if definition.mode == :sql
447
448
  return if definition.mode == :matview
448
449
  return if definition.mode == :trigger
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_ledger
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -134,7 +134,7 @@ extensions: []
134
134
  extra_rdoc_files: []
135
135
  files:
136
136
  - CHANGELOG.md
137
- - MIT-LICENSE
137
+ - LICENSE
138
138
  - README.md
139
139
  - Rakefile
140
140
  - lib/generators/standard_ledger/install/install_generator.rb
@@ -175,7 +175,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
175
175
  requirements:
176
176
  - - ">="
177
177
  - !ruby/object:Gem::Version
178
- version: '3.4'
178
+ version: '4.0'
179
179
  required_rubygems_version: !ruby/object:Gem::Requirement
180
180
  requirements:
181
181
  - - ">="
data/MIT-LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Jaryl Sim
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.