standard_ledger 0.2.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.
@@ -0,0 +1,141 @@
1
+ module StandardLedger
2
+ module Modes
3
+ # `:async` mode: applies the projection in a background job enqueued
4
+ # from the entry's `after_create_commit` callback. The job runs after
5
+ # the outer transaction has committed, so the entry is durable before
6
+ # the projection runs — and a job failure does NOT roll back the entry.
7
+ #
8
+ # Used when the projection is too expensive or stateful for the entry's
9
+ # transaction (jsonb rebuild, multi-row aggregate). The canonical
10
+ # example is nutripod's `Order#payable_balance` / `Order#fulfillable_balance`
11
+ # jsonb columns, which need to be recomputed from every PaymentRecord /
12
+ # FulfillmentRecord against the order — work that's safe to defer past
13
+ # the originating transaction.
14
+ #
15
+ # Class-form only: `:async` projections must declare `via: ProjectorClass`,
16
+ # whose `apply(target, entry)` should recompute from the log inside
17
+ # `with_lock` rather than apply a delta. Block-form per-kind handlers
18
+ # aren't safe under retry — incrementing a counter twice on retry is a
19
+ # silent data corruption bug — so block-form is rejected at registration
20
+ # time (see `Projector#projects_onto`).
21
+ #
22
+ # The strategy installs an `after_create_commit` callback once per entry
23
+ # class. The callback walks every `:async`-mode projection registered on
24
+ # the class and enqueues `StandardLedger::ProjectionJob` per (entry,
25
+ # projection) pair, honoring the optional `if:` guard.
26
+ #
27
+ # ## with_modes interop
28
+ #
29
+ # `StandardLedger.with_modes(EntryClass => :inline) { ... }` forces async
30
+ # projections to run synchronously inside the block — useful in unit
31
+ # specs that want end-to-end coverage without standing up a job runner.
32
+ # Inline-override mode skips the enqueue and runs `target.with_lock {
33
+ # projector.apply(target, entry) }` directly. The override is read in
34
+ # `#call`; specs can capture the empty job queue via `have_enqueued_job`
35
+ # / `perform_enqueued_jobs` and still observe the projection's effects.
36
+ class Async
37
+ # Install the `after_create_commit` callback on `entry_class` exactly
38
+ # once. Subsequent calls (e.g. when a second `:async` projection is
39
+ # added later in the class body) are no-ops — the same callback
40
+ # handles all `:async` projections registered on the class.
41
+ #
42
+ # @param entry_class [Class] the host entry class.
43
+ # @return [void]
44
+ # @raise [ArgumentError] when `entry_class` is not ActiveRecord-backed
45
+ # (no `after_create_commit` hook available). `:async` mode requires
46
+ # AR transactional callbacks; non-AR entry classes can't dispatch
47
+ # the post-commit enqueue.
48
+ def self.install!(entry_class)
49
+ return if entry_class.instance_variable_get(:@_standard_ledger_async_installed)
50
+
51
+ unless entry_class.respond_to?(:after_create_commit)
52
+ raise ArgumentError,
53
+ "Modes::Async requires an ActiveRecord-backed entry class on " \
54
+ "#{entry_class.name || entry_class.inspect} — the entry class must inherit " \
55
+ "from ActiveRecord::Base. Use :inline mode for plain-Ruby entry classes."
56
+ end
57
+
58
+ entry_class.after_create_commit { StandardLedger::Modes::Async.new.call(self) }
59
+ entry_class.instance_variable_set(:@_standard_ledger_async_installed, true)
60
+ end
61
+
62
+ # Enqueue `ProjectionJob` for every `:async` projection registered on
63
+ # the entry's class. Called from the `after_create_commit` callback
64
+ # installed by `.install!`.
65
+ #
66
+ # Honors the optional `if:` guard (skips enqueue when guard returns
67
+ # false) and `StandardLedger.mode_override_for(entry_class)` (when set
68
+ # to `:inline`, runs the projection synchronously inside `with_lock`
69
+ # instead of enqueueing).
70
+ #
71
+ # A nil target at enqueue time skips silently — the entry's FK was
72
+ # unset for this projection's association, so there's nothing to
73
+ # project onto. (The job has its own nil-target guard; this short-
74
+ # circuit just avoids the wasted enqueue.)
75
+ #
76
+ # @param entry [ActiveRecord::Base] the just-committed entry.
77
+ # @return [void]
78
+ def call(entry)
79
+ definitions = async_definitions_for(entry.class)
80
+ return if definitions.empty?
81
+
82
+ override = StandardLedger.mode_override_for(entry.class)
83
+
84
+ definitions.each do |definition|
85
+ next if definition.guard && !entry.instance_exec(&definition.guard)
86
+
87
+ if override == :inline
88
+ run_inline(entry, definition)
89
+ else
90
+ target = entry.public_send(definition.target_association)
91
+ next if target.nil?
92
+
93
+ job_class = StandardLedger.config.default_async_job || StandardLedger::ProjectionJob
94
+ job_class.perform_later(entry, definition.target_association.to_s)
95
+ end
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def async_definitions_for(entry_class)
102
+ return [] unless entry_class.respond_to?(:standard_ledger_projections_for)
103
+
104
+ entry_class.standard_ledger_projections_for(:async)
105
+ end
106
+
107
+ # `with_modes(EntryClass => :inline)` short-circuit: run the projector
108
+ # synchronously inside `with_lock`, mirroring the job's behavior
109
+ # without the enqueue. Skips silently when the target is nil so the
110
+ # nil-FK contract matches the enqueue path.
111
+ def run_inline(entry, definition)
112
+ target = entry.public_send(definition.target_association)
113
+ return if target.nil?
114
+
115
+ prefix = StandardLedger.config.notification_namespace
116
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
117
+
118
+ begin
119
+ target.with_lock do
120
+ definition.projector_class.new.apply(target, entry)
121
+ end
122
+ rescue StandardError => e
123
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
124
+ StandardLedger::EventEmitter.emit(
125
+ "#{prefix}.projection.failed",
126
+ entry: entry, target: target, projection: definition.target_association,
127
+ mode: :async, error: e, duration_ms: duration_ms, attempt: 1
128
+ )
129
+ raise
130
+ end
131
+
132
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
133
+ StandardLedger::EventEmitter.emit(
134
+ "#{prefix}.projection.applied",
135
+ entry: entry, target: target, projection: definition.target_association,
136
+ mode: :async, duration_ms: duration_ms, attempt: 1
137
+ )
138
+ end
139
+ end
140
+ end
141
+ 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}"
@@ -0,0 +1,50 @@
1
+ module StandardLedger
2
+ module Modes
3
+ # `:trigger` mode: the host owns a database trigger (created in a Rails
4
+ # migration) that updates the projection target's columns on every
5
+ # entry INSERT. The gem records the trigger's name and the equivalent
6
+ # rebuild SQL, but does **not** create or manage the trigger itself —
7
+ # giving a Ruby DSL the power to install/replace triggers is a deploy
8
+ # footgun (silent re-creation on `db:schema:load` against a non-empty
9
+ # DB), and triggers are versioned by `db/schema.rb` like any other DDL.
10
+ #
11
+ # Two consumers read the recorded metadata:
12
+ #
13
+ # - `StandardLedger.rebuild!` runs the rebuild SQL when invoked,
14
+ # binding `:target_id` to each target the log references. The same
15
+ # recompute path as `:sql` mode — the only difference is that the
16
+ # after-create application is performed by the database trigger
17
+ # rather than a Ruby callback.
18
+ # - The `standard_ledger:doctor` rake task verifies that the named
19
+ # trigger exists in the connected schema. Migration drift (a missing
20
+ # or renamed trigger) is caught at deploy time, not at runtime.
21
+ #
22
+ # Unlike `:inline` and `:sql`, this strategy does NOT install an
23
+ # `after_create` callback — the trigger fires from the database. The
24
+ # `install!` no-op only marks the entry class as having at least one
25
+ # `:trigger` projection registered (mirrors the `Modes::Matview` shape).
26
+ #
27
+ # See `standard_ledger-design.md` §5.3.4 for the full contract.
28
+ class Trigger
29
+ # Mark the entry class as having at least one `:trigger` projection
30
+ # registered. The actual trigger metadata lives on the entry class's
31
+ # `standard_ledger_projections` array; this method exists only to
32
+ # mirror the `Modes::*.install!` shape so `Projector#install_mode_callbacks_for`
33
+ # can dispatch uniformly across modes.
34
+ #
35
+ # No `after_create` callback is installed — the trigger runs in the
36
+ # database, not Ruby. Idempotent across multiple `:trigger`
37
+ # projections on the same entry class.
38
+ #
39
+ # @param entry_class [Class] the host entry class (unused — kept for
40
+ # parity with the other strategy classes).
41
+ # @return [void]
42
+ def self.install!(_entry_class)
43
+ # Intentionally empty: trigger projections fire from the DB, so
44
+ # there's no Ruby-side callback to wire. The DSL has already
45
+ # captured `trigger_name` and `recompute_sql` on the Definition;
46
+ # `rebuild!` and the `doctor` rake task consume those directly.
47
+ end
48
+ end
49
+ end
50
+ end
@@ -34,7 +34,7 @@ module StandardLedger
34
34
  # projections; they're `nil` for every other mode.
35
35
  Definition = Struct.new(
36
36
  :target_association, :mode, :projector_class, :handlers, :guard, :lock, :permissive,
37
- :recompute_sql, :view, :refresh_options, :options,
37
+ :recompute_sql, :trigger_name, :view, :refresh_options, :options,
38
38
  keyword_init: true
39
39
  )
40
40
 
@@ -62,12 +62,253 @@ module StandardLedger
62
62
  # metadata, e.g. `{ every: 5.minutes, concurrently: true }`. The
63
63
  # gem records this on the Definition for hosts to read when wiring
64
64
  # their scheduler; the gem does NOT auto-schedule.
65
+ # @param trigger_name [String, Symbol, nil] for `mode: :trigger` only —
66
+ # the name of the host-owned database trigger. Required when mode is
67
+ # `:trigger`; ignored otherwise. The gem does NOT create or manage
68
+ # the trigger; it records the name so `standard_ledger:doctor` can
69
+ # verify the trigger's presence in the connected schema.
65
70
  # @yield optional block-DSL form: register per-kind handlers via
66
71
  # `on(:kind) { |target, entry| ... }`. Not allowed for `mode: :matview`.
67
72
  # @return [Definition] the registered projection.
68
- def projects_onto(target_association, mode:, via: nil, if: nil, lock: nil, permissive: false, view: nil, refresh: 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)
69
76
  guard = binding.local_variable_get(:if) # `if:` is a reserved keyword
70
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
+
187
+ if mode == :async
188
+ if block
189
+ raise ArgumentError,
190
+ "projects_onto :#{target_association} mode: :async does not accept a block — " \
191
+ "use `via: ProjectorClass` whose `apply(target, entry)` recomputes from the log " \
192
+ "inside `with_lock` for retry-safety. Block-form per-kind handlers aren't safe " \
193
+ "under async retry."
194
+ end
195
+
196
+ if via.nil?
197
+ raise ArgumentError,
198
+ "projects_onto :#{target_association} mode: :async requires `via: ProjectorClass` " \
199
+ "(class-form only — see the registration error message for block-form for the why)"
200
+ end
201
+
202
+ unless lock.nil?
203
+ raise ArgumentError,
204
+ "projects_onto :#{target_association} got `lock:` with mode: :async; " \
205
+ ":async always wraps the projector in `target.with_lock` for retry-safety, " \
206
+ "so the option is redundant. Drop it."
207
+ end
208
+
209
+ if permissive
210
+ raise ArgumentError,
211
+ "projects_onto :#{target_association} got `permissive: true` with mode: :async; " \
212
+ "`permissive:` is only meaningful with the block form, and `:async` mode rejects " \
213
+ "block-form for retry-safety reasons"
214
+ end
215
+
216
+ definition = Definition.new(
217
+ target_association: target_association,
218
+ mode: mode,
219
+ projector_class: via,
220
+ handlers: {},
221
+ guard: guard,
222
+ lock: lock,
223
+ permissive: permissive,
224
+ recompute_sql: nil,
225
+ trigger_name: nil,
226
+ view: nil,
227
+ refresh_options: nil,
228
+ options: options
229
+ )
230
+
231
+ self.standard_ledger_projections = standard_ledger_projections + [ definition ]
232
+ install_mode_callbacks_for(definition)
233
+ return definition
234
+ end
235
+
236
+ if mode == :trigger
237
+ if via
238
+ raise ArgumentError,
239
+ "projects_onto :#{target_association} got `via:` with mode: :trigger; " \
240
+ "the trigger is the contract — there is no projector class. The host " \
241
+ "owns the trigger via a Rails migration; the gem only records the " \
242
+ "trigger's name and rebuild SQL"
243
+ end
244
+
245
+ unless lock.nil?
246
+ raise ArgumentError,
247
+ "projects_onto :#{target_association} got `lock:` with mode: :trigger; " \
248
+ "`lock:` is not supported by :trigger mode — the database trigger handles " \
249
+ "concurrency atomically"
250
+ end
251
+
252
+ if permissive
253
+ raise ArgumentError,
254
+ "projects_onto :#{target_association} got `permissive:` with mode: :trigger; " \
255
+ "`permissive:` is not supported by :trigger mode — the trigger doesn't " \
256
+ "dispatch through per-kind handlers"
257
+ end
258
+
259
+ if guard
260
+ raise ArgumentError,
261
+ "projects_onto :#{target_association} got `if:` with mode: :trigger; " \
262
+ "`if:` is not supported by :trigger mode — the database trigger fires " \
263
+ "unconditionally from the DB on INSERT, so a Ruby-side guard would " \
264
+ "silently never run"
265
+ end
266
+
267
+ if trigger_name.nil? || trigger_name.to_s.empty?
268
+ raise ArgumentError,
269
+ "projects_onto :#{target_association} requires `trigger_name: \"...\"` for mode: :trigger; " \
270
+ "the gem records the trigger's name so `standard_ledger:doctor` can verify its presence"
271
+ end
272
+
273
+ unless block
274
+ raise ArgumentError,
275
+ "projects_onto :#{target_association} requires a block with `rebuild_sql \"...\"` for mode: :trigger"
276
+ end
277
+
278
+ dsl = TriggerDsl.new
279
+ dsl.instance_eval(&block)
280
+
281
+ if dsl.rebuild_sql_text.nil?
282
+ raise ArgumentError,
283
+ "projects_onto :#{target_association} block is empty; mode: :trigger requires a `rebuild_sql \"...\"` clause"
284
+ end
285
+
286
+ unless dsl.rebuild_sql_text.include?(":target_id")
287
+ raise ArgumentError,
288
+ "projects_onto :#{target_association} rebuild SQL must include the `:target_id` placeholder; " \
289
+ "it's bound to each target's id when `StandardLedger.rebuild!` walks the log"
290
+ end
291
+
292
+ definition = Definition.new(
293
+ target_association: target_association,
294
+ mode: mode,
295
+ projector_class: nil,
296
+ handlers: {},
297
+ guard: guard,
298
+ lock: lock,
299
+ permissive: permissive,
300
+ recompute_sql: dsl.rebuild_sql_text,
301
+ trigger_name: trigger_name.to_s,
302
+ view: nil,
303
+ refresh_options: nil,
304
+ options: options
305
+ )
306
+
307
+ self.standard_ledger_projections = standard_ledger_projections + [ definition ]
308
+ install_mode_callbacks_for(definition)
309
+ return definition
310
+ end
311
+
71
312
  if mode == :sql
72
313
  if via
73
314
  raise ArgumentError,
@@ -118,6 +359,7 @@ module StandardLedger
118
359
  lock: lock,
119
360
  permissive: permissive,
120
361
  recompute_sql: dsl.recompute_sql,
362
+ trigger_name: nil,
121
363
  view: nil,
122
364
  refresh_options: nil,
123
365
  options: options
@@ -150,6 +392,7 @@ module StandardLedger
150
392
  lock: lock,
151
393
  permissive: permissive,
152
394
  recompute_sql: nil,
395
+ trigger_name: nil,
153
396
  view: view.to_s,
154
397
  refresh_options: refresh || {},
155
398
  options: options
@@ -200,6 +443,7 @@ module StandardLedger
200
443
  lock: lock,
201
444
  permissive: permissive,
202
445
  recompute_sql: nil,
446
+ trigger_name: nil,
203
447
  view: nil,
204
448
  refresh_options: nil,
205
449
  options: options
@@ -222,10 +466,14 @@ module StandardLedger
222
466
  case definition.mode
223
467
  when :inline
224
468
  StandardLedger::Modes::Inline.install!(self)
469
+ when :async
470
+ StandardLedger::Modes::Async.install!(self)
225
471
  when :sql
226
472
  StandardLedger::Modes::Sql.install!(self)
227
473
  when :matview
228
474
  StandardLedger::Modes::Matview.install!(self)
475
+ when :trigger
476
+ StandardLedger::Modes::Trigger.install!(self)
229
477
  end
230
478
  end
231
479
 
@@ -275,6 +523,12 @@ module StandardLedger
275
523
  "the recompute SQL runs through `Modes::Sql#call` directly with no per-kind dispatch"
276
524
  end
277
525
 
526
+ if definition.mode == :trigger
527
+ raise Error,
528
+ "apply_projection! is not supported for mode: :trigger; " \
529
+ "the database trigger fires from the DB on INSERT, not from Ruby"
530
+ end
531
+
278
532
  return false if definition.guard && !instance_exec(&definition.guard)
279
533
 
280
534
  target = public_send(definition.target_association)
@@ -357,5 +611,28 @@ module StandardLedger
357
611
  @recompute_sql = sql
358
612
  end
359
613
  end
614
+
615
+ # Internal collector for the `:trigger`-mode block-DSL form. Captures
616
+ # the `rebuild_sql "..."` clause's SQL string. The trigger itself is
617
+ # owned by the host (created in a Rails migration); the gem only
618
+ # records this rebuild SQL so `StandardLedger.rebuild!` can recompute
619
+ # the projection from the log when invoked. Like `:sql` mode, the SQL
620
+ # must be expressible as a single statement with `:target_id` bound
621
+ # from each target's id at rebuild time.
622
+ class TriggerDsl
623
+ attr_reader :rebuild_sql_text
624
+
625
+ def rebuild_sql(sql)
626
+ unless sql.is_a?(String)
627
+ raise ArgumentError, "rebuild_sql requires a SQL string; got #{sql.class}"
628
+ end
629
+ unless @rebuild_sql_text.nil?
630
+ raise ArgumentError,
631
+ "rebuild_sql called more than once in the same projects_onto block; " \
632
+ ":trigger mode supports exactly one rebuild_sql clause per projection"
633
+ end
634
+ @rebuild_sql_text = sql
635
+ end
636
+ end
360
637
  end
361
638
  end
@@ -1,3 +1,3 @@
1
1
  module StandardLedger
2
- VERSION = "0.2.0"
2
+ VERSION = "0.4.0"
3
3
  end