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 +4 -4
- data/CHANGELOG.md +78 -2
- data/LICENSE +22 -0
- data/README.md +58 -3
- 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 +3 -3
- data/MIT-LICENSE +0 -21
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 89af058a7d94051bc75f8e853d1be397270b52bc5a02020dfdd41484c6cacda5
|
|
4
|
+
data.tar.gz: 50eef558981621f3561c42095d294f87f09d7ed18e8fc5a5919c0801262f257b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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.
|
|
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
|
|
286
|
+
The gem is published on RubyGems:
|
|
287
287
|
|
|
288
288
|
```ruby
|
|
289
|
-
gem "standard_ledger",
|
|
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 [
|
|
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
|
-
#
|
|
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
|
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.
|
|
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
|
-
-
|
|
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: '
|
|
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.
|