standard_ledger 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +51 -0
- data/lib/standard_ledger/entry.rb +99 -10
- data/lib/standard_ledger/errors.rb +10 -0
- data/lib/standard_ledger/modes/matview.rb +35 -4
- data/lib/standard_ledger/projector.rb +112 -1
- data/lib/standard_ledger/version.rb +1 -1
- data/lib/standard_ledger.rb +1 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 411ad3db9a71ca8adddecc84d407dabddbd167c7ed00e534e975a700b3a2e8f2
|
|
4
|
+
data.tar.gz: aeb938a4e1fb16c6df67fbb76a93a5687c40116be040df5da44e65541ce4d282
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2d0cd11b66c8d22b76cf9444e8840d92c35ccd81d4eba224e8123b208aa5c706503bc9cf6e9bbf1dc7bff564df4728adac79eeeacc254a976efb8cfa1fd0907a
|
|
7
|
+
data.tar.gz: 5f352fe00a36e01ef43aa4d8efb197a32314c43f0b31657342996dc91bcf422933e4076b851813bd6c6fd7ab69a52d18f53e9650fbced76292b9deca52b6ce0b
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,57 @@ project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
8
8
|
|
|
9
9
|
Nothing yet.
|
|
10
10
|
|
|
11
|
+
## [0.4.0] - 2026-05-07
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- New `:manual` projection mode. Records the projection contract
|
|
15
|
+
(target + projector class) without installing any callback —
|
|
16
|
+
intended for AASM/state-machine entries whose interesting lifecycle
|
|
17
|
+
event is a transition rather than `after_create`. Hosts invoke the
|
|
18
|
+
projector explicitly from operation code; the gem keeps the
|
|
19
|
+
contract introspectable (`standard_ledger_projections`) and
|
|
20
|
+
log-replayable via `StandardLedger.rebuild!`. Requires
|
|
21
|
+
`via: ProjectorClass`; rejects blocks, locks, and `permissive:`.
|
|
22
|
+
- New `allow_destroy:` keyword on `ledger_entry`. When `true`, an
|
|
23
|
+
immutable entry permits `destroy` (including `dependent: :destroy`
|
|
24
|
+
cascades from a parent record) while still blocking `save`/`update`.
|
|
25
|
+
The default is `false` — preserves the strict journal contract.
|
|
26
|
+
Use this when an owning record's destroy cascade needs to reap
|
|
27
|
+
events for sandbox tear-down or GDPR erasure.
|
|
28
|
+
- New `counters:` shortcut for `:inline` projections. A
|
|
29
|
+
`kind => column` Hash that synthesises one
|
|
30
|
+
`on(kind) { |t, _| t.class.increment_counter(col, t.id) }` per
|
|
31
|
+
entry. Direct UPDATE (the class-method form) is intentional — it
|
|
32
|
+
invalidates the SQL query cache for the target table, keeping
|
|
33
|
+
multiple sibling-entry creates inside a single transaction (e.g.
|
|
34
|
+
via `accepts_nested_attributes_for`) from losing updates against
|
|
35
|
+
stale cached reads. Block form remains available for non-counter
|
|
36
|
+
projections.
|
|
37
|
+
- New `rebuild_sql:` keyword on `:trigger` mode. Equivalent to the
|
|
38
|
+
block-DSL `rebuild_sql "..."` clause, callable without a block.
|
|
39
|
+
- Partial unique indexes are now accepted by the idempotency-index
|
|
40
|
+
validator when their predicate is the canonical
|
|
41
|
+
`<idempotency_key> IS NOT NULL` shape. Other predicates still raise
|
|
42
|
+
`MissingIdempotencyIndex` with a clearer error message.
|
|
43
|
+
- New `StandardLedger::RefreshInsideTransaction` error. Raised when
|
|
44
|
+
`StandardLedger.refresh!(view, concurrently: true)` is called
|
|
45
|
+
inside an open transaction — Postgres rejects
|
|
46
|
+
`REFRESH MATERIALIZED VIEW CONCURRENTLY` inside transaction blocks,
|
|
47
|
+
and the gem now catches this at the boundary instead of letting
|
|
48
|
+
`PG::ActiveSqlTransaction` escape mid-call. The non-concurrent
|
|
49
|
+
form is still permitted by Postgres inside transactions and is
|
|
50
|
+
unaffected. No SQL is issued and no `.refreshed`/`.failed` event
|
|
51
|
+
fires when the guard rejects.
|
|
52
|
+
- `docs/MIGRATION_GUIDE.md` covering the five real-world adoption
|
|
53
|
+
paths (counter caches, custom inline logic, bespoke jobs, existing
|
|
54
|
+
Postgres triggers, AASM state machines) and the cascade-delete /
|
|
55
|
+
refresh-in-transaction edge cases.
|
|
56
|
+
|
|
57
|
+
### Documentation
|
|
58
|
+
- `ledger_entry`'s YARD now notes that a single-symbol `scope:` is
|
|
59
|
+
normalised to a flat array on the stored config; assertions in
|
|
60
|
+
host specs should compare against `[:foo]`, not `:foo`.
|
|
61
|
+
|
|
11
62
|
## [0.3.0] - 2026-05-05
|
|
12
63
|
|
|
13
64
|
### Added
|
|
@@ -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
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
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
|
-
|
|
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: :
|
|
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
|
|
80
|
-
# the
|
|
81
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
data/lib/standard_ledger.rb
CHANGED
|
@@ -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
|