standard_ledger 0.2.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,66 @@
1
+ # StandardLedger configuration
2
+ # Generated by: rails g standard_ledger:install
3
+ #
4
+ # StandardLedger captures the recurring "immutable journal entry → N
5
+ # aggregate projections" pattern as a declarative DSL on host ActiveRecord
6
+ # models. See https://github.com/rarebit-one/standard_ledger for the gem
7
+ # README.
8
+ #
9
+ # All commented-out lines below show the public DSL — uncomment and edit
10
+ # the ones that apply to your app.
11
+
12
+ StandardLedger.configure do |c|
13
+ # ---------------------------------------------------------------------------
14
+ # Async mode
15
+ # ---------------------------------------------------------------------------
16
+
17
+ # The ActiveJob class used by `:async` mode projections. Defaults to
18
+ # `StandardLedger::ProjectionJob`. Hosts can supply their own job class
19
+ # (e.g. for custom queue routing or per-projection telemetry).
20
+ # Default: nil (resolved lazily to StandardLedger::ProjectionJob)
21
+ # c.default_async_job = Orders::FulfillableProjectionJob
22
+
23
+ # Number of times an `:async` projection will retry before dead-lettering.
24
+ # Default: 3.
25
+ # c.default_async_retries = 3
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Matview mode
29
+ # ---------------------------------------------------------------------------
30
+
31
+ # Scheduler backend for `:matview` refresh jobs.
32
+ # One of :solid_queue, :sidekiq_cron, :custom. Default: :solid_queue.
33
+ # c.scheduler = :solid_queue
34
+
35
+ # Default refresh strategy for `:matview` projections.
36
+ # :concurrent (REFRESH MATERIALIZED VIEW CONCURRENTLY — requires a unique
37
+ # index on the view) or :blocking. Default: :concurrent.
38
+ # c.matview_refresh_strategy = :concurrent
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Notifications
42
+ # ---------------------------------------------------------------------------
43
+
44
+ # Prefix for ActiveSupport::Notifications events emitted by the gem.
45
+ # Default: "standard_ledger". Events:
46
+ # <prefix>.entry.created
47
+ # <prefix>.projection.applied
48
+ # <prefix>.projection.failed
49
+ # c.notification_namespace = "standard_ledger"
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Host Result interop
53
+ # ---------------------------------------------------------------------------
54
+ # Optional: return the host's Result type from StandardLedger.post.
55
+ # When both result_class and result_adapter are set, post() returns
56
+ # instances of result_class via the adapter; otherwise it returns
57
+ # StandardLedger::Result.
58
+ #
59
+ # The adapter receives keyword args:
60
+ # success:, value:, errors:, entry:, idempotent:, projections:
61
+ #
62
+ # c.result_class = ApplicationOperation::Result
63
+ # c.result_adapter = ->(success:, value:, errors:, entry:, idempotent:, projections:) {
64
+ # ApplicationOperation::Result.new(success:, value: value || entry, errors:)
65
+ # }
66
+ end
@@ -0,0 +1,62 @@
1
+ module StandardLedger
2
+ # Host-configurable settings, populated via `StandardLedger.configure { |c| ... }`
3
+ # in an initializer. All attributes have sensible defaults; hosts only override
4
+ # what they need.
5
+ #
6
+ # @see StandardLedger.configure
7
+ class Config
8
+ # The ActiveJob class used by `:async` mode projections. Defaults to
9
+ # `StandardLedger::ProjectionJob`. Hosts can supply their own job class
10
+ # (e.g. for custom queue routing or per-projection telemetry) via
11
+ # `c.default_async_job = Orders::FulfillableProjectionJob`.
12
+ attr_accessor :default_async_job
13
+
14
+ # Number of times an `:async` projection will retry before dead-lettering.
15
+ # Default: 3.
16
+ attr_accessor :default_async_retries
17
+
18
+ # Scheduler backend for `:matview` refresh jobs. One of
19
+ # `:solid_queue`, `:sidekiq_cron`, `:custom`. Default: `:solid_queue`
20
+ # (matches all four consuming apps).
21
+ attr_accessor :scheduler
22
+
23
+ # Default refresh strategy for `:matview` projections. Either
24
+ # `:concurrent` (REFRESH MATERIALIZED VIEW CONCURRENTLY — requires a
25
+ # unique index on the view) or `:blocking`. Default: `:concurrent`.
26
+ attr_accessor :matview_refresh_strategy
27
+
28
+ # Optional: the host application's Result class. When set together with
29
+ # `result_adapter`, `StandardLedger.post` returns instances of this class
30
+ # instead of `StandardLedger::Result`.
31
+ attr_accessor :result_class
32
+
33
+ # Optional: a callable that translates the gem's result fields into the
34
+ # host's Result type. Receives keyword args:
35
+ # `success:, value:, errors:, entry:, idempotent:, projections:`.
36
+ # Required when `result_class` is set.
37
+ attr_accessor :result_adapter
38
+
39
+ # Prefix for `ActiveSupport::Notifications` events emitted by the gem.
40
+ # Default: `"standard_ledger"`. Events:
41
+ # `<prefix>.entry.created`, `<prefix>.projection.applied`,
42
+ # `<prefix>.projection.failed`, `<prefix>.projection.refreshed`,
43
+ # `<prefix>.projection.rebuilt`.
44
+ attr_accessor :notification_namespace
45
+
46
+ def initialize
47
+ @default_async_job = nil # resolved lazily to avoid loading the job constant before Rails boots
48
+ @default_async_retries = 3
49
+ @scheduler = :solid_queue
50
+ @matview_refresh_strategy = :concurrent
51
+ @result_class = nil
52
+ @result_adapter = nil
53
+ @notification_namespace = "standard_ledger"
54
+ end
55
+
56
+ # True when the host has wired up its own Result type. When false, the gem
57
+ # returns its built-in `StandardLedger::Result`.
58
+ def custom_result?
59
+ !result_class.nil? && !result_adapter.nil?
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,19 @@
1
+ module StandardLedger
2
+ # Boot hook for Rails apps. The engine registers no routes and provides no
3
+ # tables — its only role is to ensure the gem's notification subscribers
4
+ # (if any are registered by the host) are wired up after the host's
5
+ # initializers have finished running.
6
+ #
7
+ # We hook `after: :load_config_initializers` so any host-side
8
+ # `StandardLedger.configure` block in `config/initializers/*` has finished
9
+ # before subscribers are attached.
10
+ class Engine < ::Rails::Engine
11
+ isolate_namespace StandardLedger
12
+
13
+ initializer "standard_ledger.notifications", after: :load_config_initializers do
14
+ # Notification wiring lands here in a follow-up PR. Today this is a
15
+ # no-op — the gem emits events but ships no internal subscribers; hosts
16
+ # subscribe directly via ActiveSupport::Notifications.subscribe.
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,253 @@
1
+ require "active_support/concern"
2
+
3
+ module StandardLedger
4
+ # Marks an ActiveRecord model as a ledger entry: an immutable, append-only
5
+ # row that may project onto one or more aggregate targets.
6
+ #
7
+ # Including this concern installs:
8
+ # - the `ledger_entry` class macro (declares immutability + idempotency)
9
+ # - read-only behavior post-creation (when `immutable: true`, the default)
10
+ # - idempotency-by-unique-index (when `idempotency_key:` is non-nil)
11
+ #
12
+ # Projection registration happens via the separate `Projector` concern —
13
+ # the two are decoupled so that an entry can be marked immutable without
14
+ # also opting into projections, and vice versa.
15
+ #
16
+ # @example
17
+ # class VoucherRecord < ApplicationRecord
18
+ # include StandardLedger::Entry
19
+ #
20
+ # ledger_entry kind: :action,
21
+ # idempotency_key: :serial_no,
22
+ # scope: :organisation_id
23
+ # end
24
+ module Entry
25
+ extend ActiveSupport::Concern
26
+
27
+ class_methods do
28
+ # Declare the entry's contract. Stores the configuration on the class
29
+ # for later inspection by `StandardLedger.post`, `Projection.rebuild!`,
30
+ # and the `standard_ledger:doctor` rake task.
31
+ #
32
+ # @param kind [Symbol] the column holding the entry's kind/action
33
+ # discriminator. Defaults to `:kind`.
34
+ # @param idempotency_key [Symbol, nil] the column whose unique index
35
+ # guards against duplicate inserts. `nil` means the entry is not
36
+ # idempotent — explicitly opt-in to that.
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)
42
+ self.standard_ledger_entry_config = {
43
+ kind: kind,
44
+ idempotency_key: idempotency_key,
45
+ scope: Array(scope).compact,
46
+ immutable: immutable
47
+ }
48
+ self.standard_ledger_idempotency_index_validated = false
49
+ end
50
+
51
+ def standard_ledger_entry?
52
+ !standard_ledger_entry_config.nil?
53
+ end
54
+
55
+ # Override AR's `create!` to add idempotency-by-unique-index semantics.
56
+ # When the configured unique constraint trips, look up and return the
57
+ # existing row with `idempotent? == true` instead of raising.
58
+ #
59
+ # @note Block-form `create! { |r| r.field = val }` is not supported for
60
+ # the idempotent rescue: AR passes `attributes = nil` in that path so
61
+ # we can't construct the find_by lookup. The rescue still functions
62
+ # for the rest of the create — a colliding insert from a block-form
63
+ # call simply re-raises `RecordNotUnique` like vanilla ActiveRecord.
64
+ def create!(attributes = nil, &block)
65
+ config = standard_ledger_entry_config
66
+ return super if config.nil? || config[:idempotency_key].nil?
67
+
68
+ validate_standard_ledger_idempotency_index!
69
+
70
+ super
71
+ rescue ActiveRecord::RecordNotUnique => e
72
+ raise unless standard_ledger_idempotency_violation?(e)
73
+
74
+ existing = find_existing_standard_ledger_entry(attributes)
75
+ raise if existing.nil?
76
+
77
+ existing.instance_variable_set(:@_standard_ledger_idempotent, true)
78
+ existing
79
+ end
80
+
81
+ # Verify that the table has a unique index covering exactly
82
+ # `[*scope, idempotency_key]` (column set equality; order-insensitive).
83
+ # Cached so the introspection runs once per class.
84
+ #
85
+ # The check-then-set on `standard_ledger_idempotency_index_validated`
86
+ # has a benign race: two threads can both observe `false`, both run the
87
+ # introspection, and both flip the flag to `true`. That's intentional
88
+ # — the validation is pure and idempotent, so duplicate work is cheap
89
+ # and the result is identical. No mutex needed; do not add one.
90
+ def validate_standard_ledger_idempotency_index!
91
+ return if standard_ledger_idempotency_index_validated
92
+
93
+ config = standard_ledger_entry_config
94
+ return if config.nil? || config[:idempotency_key].nil?
95
+
96
+ required = (config[:scope] + [ config[:idempotency_key] ]).map(&:to_s).to_set
97
+ indexes = connection.indexes(table_name)
98
+
99
+ match = indexes.any? do |index|
100
+ index.unique && index.columns.map(&:to_s).to_set == required
101
+ end
102
+
103
+ unless match
104
+ raise StandardLedger::MissingIdempotencyIndex,
105
+ "#{name} declares idempotency_key: #{config[:idempotency_key].inspect} " \
106
+ "with scope: #{config[:scope].inspect} but no matching unique index " \
107
+ "covers exactly #{required.to_a.sort.inspect} on `#{table_name}`."
108
+ end
109
+
110
+ self.standard_ledger_idempotency_index_validated = true
111
+ end
112
+
113
+ private
114
+
115
+ def find_existing_standard_ledger_entry(attributes)
116
+ return nil if attributes.nil?
117
+
118
+ config = standard_ledger_entry_config
119
+ lookup_columns = config[:scope] + [ config[:idempotency_key] ]
120
+ attrs = attributes.is_a?(Hash) ? attributes.transform_keys(&:to_sym) : {}
121
+ lookup = lookup_columns.each_with_object({}) do |col, memo|
122
+ memo[col] = attrs[col.to_sym]
123
+ end
124
+
125
+ # Bail if any lookup value is nil — `find_by` would emit
126
+ # `WHERE col IS NULL` and could match an unrelated row whose column
127
+ # legitimately holds NULL. We require all idempotency columns to be
128
+ # present in `attributes` to make a confident match.
129
+ return nil if lookup.any? { |_, value| value.nil? }
130
+
131
+ find_by(lookup)
132
+ end
133
+
134
+ # Confirm the RecordNotUnique was raised by *our* idempotency index,
135
+ # not some other unique constraint on the table (surrogate key,
136
+ # business column, etc.). The wrapped DB exception's message usually
137
+ # mentions the index name or the column list — a substring match on
138
+ # each idempotency column name is good enough across PostgreSQL and
139
+ # SQLite without parsing vendor-specific formats.
140
+ #
141
+ # Adapter caveat: MySQL's unique-violation message contains only the
142
+ # index name (e.g. `Duplicate entry 'val' for key 'idx_name'`), not
143
+ # the column list. So this check returns false on MySQL unless the
144
+ # index is named after its columns. The fail-closed behavior re-raises
145
+ # the original RecordNotUnique, which is the correct outcome for an
146
+ # unrecognized violation — never the wrong one for a misclassified
147
+ # one. None of the host apps target MySQL today; revisit if that
148
+ # changes.
149
+ def standard_ledger_idempotency_violation?(exception)
150
+ config = standard_ledger_entry_config
151
+ columns = (config[:scope] + [ config[:idempotency_key] ]).map(&:to_s)
152
+ message = String(exception.message) + String(exception.cause&.message)
153
+
154
+ columns.all? { |col| message.include?(col) }
155
+ end
156
+ end
157
+
158
+ included do
159
+ class_attribute :standard_ledger_entry_config, instance_writer: false
160
+ class_attribute :standard_ledger_idempotency_index_validated, instance_writer: false
161
+ self.standard_ledger_entry_config = nil
162
+ self.standard_ledger_idempotency_index_validated = false
163
+
164
+ # The destroy guard only matters for AR includers (the production case);
165
+ # plain Ruby classes that include Entry for testing the DSL surface
166
+ # get the macro registration without the callback. AR's `readonly?`
167
+ # path covers save/update on persisted rows; this catch-all stops
168
+ # `destroy` for the AR case.
169
+ if respond_to?(:before_destroy)
170
+ before_destroy :standard_ledger_raise_readonly, if: :standard_ledger_immutable?
171
+ end
172
+
173
+ # Emit `<namespace>.entry.created` after the row is durably committed
174
+ # so subscribers (audit logs, metric pipelines) see it only when the
175
+ # entry is real. Idempotent returns from `create!`'s rescue do not
176
+ # fire `after_commit on: :create` (no INSERT happened), which is the
177
+ # correct behavior: the original write fired the event already.
178
+ if respond_to?(:after_commit)
179
+ after_commit :standard_ledger_emit_entry_created, on: :create
180
+ end
181
+ end
182
+
183
+ # Returns true when this row was returned from an idempotent `create!`
184
+ # rescue — i.e. an existing row matched the unique constraint and was
185
+ # returned instead of inserted.
186
+ def idempotent?
187
+ !!@_standard_ledger_idempotent
188
+ end
189
+
190
+ # AR consults `readonly?` from `save`/`update` paths; raising
191
+ # ReadOnlyRecord here matches the ActiveRecord contract for persisted
192
+ # immutable rows. New, unpersisted instances stay writable so the
193
+ # initial INSERT can land.
194
+ def readonly?
195
+ return super unless standard_ledger_immutable?
196
+
197
+ !new_record?
198
+ end
199
+
200
+ # Returns the entry's belongs_to targets keyed by association name.
201
+ # Used by the `entry.created` notification payload and by
202
+ # `StandardLedger.post`'s telemetry. Skips polymorphic and missing
203
+ # associations so the payload only includes what's actually present.
204
+ #
205
+ # Performance trade-off: this fires from `after_commit`, where AR may
206
+ # have cleared the association cache. Each `public_send(reflection.name)`
207
+ # can therefore issue a SELECT to reload the cached target. For the
208
+ # typical 1–2 belongs_to entry, that's negligible. If profiling on a
209
+ # high-cardinality entry shows this matters, capture targets earlier
210
+ # (e.g. in `before_create`) and stash them on the instance — deferred
211
+ # to a future PR. Notably, an inline-mode caller has already resolved
212
+ # these targets by the time `after_commit` runs, so the SELECTs would
213
+ # only happen for entries with belongs_to associations that are *not*
214
+ # registered as projection targets.
215
+ #
216
+ # @return [Hash{Symbol => ActiveRecord::Base}]
217
+ def standard_ledger_targets
218
+ return {} unless self.class.respond_to?(:reflect_on_all_associations)
219
+
220
+ self.class.reflect_on_all_associations(:belongs_to).each_with_object({}) do |reflection, memo|
221
+ next if reflection.polymorphic?
222
+
223
+ target = public_send(reflection.name)
224
+ memo[reflection.name] = target unless target.nil?
225
+ end
226
+ end
227
+
228
+ private
229
+
230
+ def standard_ledger_immutable?
231
+ config = self.class.standard_ledger_entry_config
232
+ !config.nil? && config[:immutable]
233
+ end
234
+
235
+ def standard_ledger_raise_readonly
236
+ raise ActiveRecord::ReadOnlyRecord
237
+ end
238
+
239
+ # Publish `<namespace>.entry.created` once the row is durably committed.
240
+ # `after_commit on: :create` only fires for real INSERTs, so idempotent
241
+ # returns from the `create!` rescue path are correctly skipped.
242
+ def standard_ledger_emit_entry_created
243
+ config = self.class.standard_ledger_entry_config
244
+ kind_value = config ? public_send(config[:kind]) : nil
245
+ prefix = StandardLedger.config.notification_namespace
246
+
247
+ StandardLedger::EventEmitter.emit(
248
+ "#{prefix}.entry.created",
249
+ entry: self, kind: kind_value, targets: standard_ledger_targets
250
+ )
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,33 @@
1
+ module StandardLedger
2
+ class Error < StandardError; end
3
+
4
+ # Raised at registration time when a `projects_onto` block declares an
5
+ # `on(:kind)` for a kind that the entry's enum/set does not include, or
6
+ # when an entry is posted with a kind that no projection has registered
7
+ # a handler for. Use `permissive: true` on the projection to opt out.
8
+ class UnhandledKind < Error; end
9
+
10
+ # Raised by `StandardLedger.rebuild!` when the projector does not implement
11
+ # `rebuild` and is therefore not replayable from the entry log. Delta-based
12
+ # projectors (e.g. `increment_counter`-flavored) typically raise this
13
+ # because they cannot be reconstructed without summing the full log.
14
+ class NotRebuildable < Error; end
15
+
16
+ # Raised at boot when an Entry declares `idempotency_key:` but no matching
17
+ # unique index exists on the entry table. Caught early instead of silently
18
+ # admitting duplicates at runtime.
19
+ class MissingIdempotencyIndex < Error; end
20
+
21
+ # Raised by `StandardLedger.post` when the inline portion of fan-out
22
+ # succeeded but enqueuing one or more async projections failed. The entry
23
+ # itself is durable; callers may choose to roll back or accept-and-log.
24
+ class PartialFailure < Error
25
+ attr_reader :enqueued, :failed
26
+
27
+ def initialize(enqueued:, failed:)
28
+ @enqueued = enqueued
29
+ @failed = failed
30
+ super("Enqueued #{enqueued.size} projections; #{failed.size} failed to enqueue")
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,50 @@
1
+ module StandardLedger
2
+ # Internal helper that emits StandardLedger lifecycle events through whichever
3
+ # event reporter is live in the host process.
4
+ #
5
+ # - On Rails 8.1+, `Rails.event.notify(name, **payload)` is the canonical bus.
6
+ # - On older Rails (or any host without the structured reporter), we fall back
7
+ # to `ActiveSupport::Notifications.instrument(name, payload)`.
8
+ #
9
+ # Detection is performed at *call time* — the gem is required before Rails has
10
+ # finished booting, so we cannot cache the decision at load time.
11
+ #
12
+ # @api private
13
+ module EventEmitter
14
+ module_function
15
+
16
+ # Emit a single event. Both backends are best-effort: any exception raised
17
+ # by a subscriber is swallowed so ledger observability never takes down a
18
+ # host's request path (the projection has already either succeeded or
19
+ # been rolled back by the time we emit).
20
+ def emit(event_name, payload)
21
+ if (bus = rails_event_bus)
22
+ bus.notify(event_name, **payload)
23
+ else
24
+ ::ActiveSupport::Notifications.instrument(event_name, payload)
25
+ end
26
+ rescue => e
27
+ warn "[StandardLedger] event emit for #{event_name.inspect} failed: #{e.class}: #{e.message}"
28
+ end
29
+
30
+ # Returns the Rails 8.1+ structured event bus when available, or `nil`
31
+ # to signal the AS::Notifications fallback. Single accessor so `emit`
32
+ # invokes `Rails.event` only once per call.
33
+ def rails_event_bus
34
+ return nil unless defined?(::Rails) &&
35
+ ::Rails.respond_to?(:event) &&
36
+ ::Rails.event.respond_to?(:notify)
37
+
38
+ ::Rails.event
39
+ end
40
+
41
+ # Boolean shorthand kept for callers (and specs) that just want to know
42
+ # whether the modern bus is live.
43
+ def rails_event_available?
44
+ !rails_event_bus.nil?
45
+ end
46
+
47
+ # Hides the singleton copy that `module_function` generated above.
48
+ private_class_method :rails_event_bus
49
+ end
50
+ end
@@ -0,0 +1,28 @@
1
+ require "active_job"
2
+
3
+ module StandardLedger
4
+ # Thin ActiveJob wrapper that delegates to `StandardLedger.refresh!`. Hosts
5
+ # point their scheduler (SolidQueue Recurring Tasks, sidekiq-cron, etc.) at
6
+ # this job class with the view name as the argument. The gem deliberately
7
+ # does not auto-schedule — schedule cadence and backend selection is a host
8
+ # concern (the host's scheduler config has the wider context: queue routing,
9
+ # recurring task DSL, etc.).
10
+ #
11
+ # The job runs on ActiveJob's `:default` queue. Hosts running high-frequency
12
+ # refreshes (e.g. every minute) on a shared `:default` queue may want to
13
+ # isolate matview refreshes onto a dedicated queue so a slow refresh doesn't
14
+ # starve other latency-sensitive jobs — subclass and override `queue_as`
15
+ # (e.g. `queue_as :standard_ledger`) and point the scheduler at the
16
+ # subclass.
17
+ #
18
+ # @example SolidQueue Recurring Tasks (config/recurring.yml)
19
+ # refresh_user_prompt_inventories:
20
+ # class: StandardLedger::MatviewRefreshJob
21
+ # args: ["user_prompt_inventories", { concurrently: true }]
22
+ # schedule: "every 5 minutes"
23
+ class MatviewRefreshJob < ::ActiveJob::Base
24
+ def perform(view_name, concurrently: nil)
25
+ StandardLedger.refresh!(view_name, concurrently: concurrently)
26
+ end
27
+ end
28
+ end