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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 276066f9976675bcd6ed7220d0df2d72518c355eab43036807cf53c1c1da7e00
4
- data.tar.gz: 01de25809973bb72ed2414f32f5c6c9398f45f2d44fed75bfdb1743c9beaaedc
3
+ metadata.gz: 411ad3db9a71ca8adddecc84d407dabddbd167c7ed00e534e975a700b3a2e8f2
4
+ data.tar.gz: aeb938a4e1fb16c6df67fbb76a93a5687c40116be040df5da44e65541ce4d282
5
5
  SHA512:
6
- metadata.gz: bc772c009801e0bf6ab2e3568e95aeff7305413fce076742d89dfb62388cada99a168659fc0745807ca661c31bd30cc33529b105c4f7bd163a91090d3b5603ab
7
- data.tar.gz: 1002bacfe4f93410a2d06ffda6dec585328c0c248d518f3e0e12148a9bd5effb451f02dbb1967569b3e2d30b3d8c71bcc4abba53efa7e41d218f451ee21d659c
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
- # @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.4.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.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim