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.
@@ -14,7 +14,10 @@ require "standard_ledger/projector"
14
14
  require "standard_ledger/modes/inline"
15
15
  require "standard_ledger/modes/sql"
16
16
  require "standard_ledger/modes/matview"
17
+ require "standard_ledger/modes/trigger"
18
+ require "standard_ledger/modes/async"
17
19
  require "standard_ledger/jobs/matview_refresh_job"
20
+ require "standard_ledger/jobs/projection_job"
18
21
  require "standard_ledger/engine" if defined?(::Rails::Engine)
19
22
 
20
23
  # StandardLedger captures the recurring "immutable journal entry → N
@@ -197,9 +200,18 @@ module StandardLedger
197
200
  # refresh *is* rebuild. Postgres has no partial-refresh primitive,
198
201
  # so `target:` / `target_class:` scope arguments are ignored for
199
202
  # `:matview` projections and the full view is always refreshed.
200
- # - `:async`, `:sql`, `:trigger` modes are not yet supported by
201
- # `rebuild!`; they raise `StandardLedger::Error`. Each lands with
202
- # its mode's own PR.
203
+ # - `:sql` and `:trigger` projections rebuild by running their
204
+ # recorded rebuild SQL with `:target_id` bound to each target's
205
+ # id. For `:trigger`, the database trigger fires on entry INSERT;
206
+ # `rebuild!` runs the same logical recompute against each target
207
+ # the log references. The gem does NOT verify or recreate the
208
+ # trigger here — `standard_ledger:doctor` is the deploy-time
209
+ # check for trigger presence.
210
+ # - `:async` projections rebuild via the same per-target semantics as
211
+ # `:inline` (delegates to `definition.projector_class.new.rebuild(target)`).
212
+ # The mode difference is only in the after-create path (in-transaction
213
+ # vs. post-commit job), not in the rebuild path, which always runs
214
+ # synchronously.
203
215
  #
204
216
  # Atomicity: each (target, projection) pair runs in its own
205
217
  # transaction. A failure mid-loop is **not** rolled back — earlier
@@ -281,7 +293,7 @@ module StandardLedger
281
293
  validate_rebuildable_projector!(entry_class, definition)
282
294
 
283
295
  each_rebuild_target(entry_class, definition, target: target, batch_size: batch_size) do |t|
284
- if definition.mode == :sql
296
+ if definition.mode == :sql || definition.mode == :trigger
285
297
  rebuild_one_sql(entry_class, definition, t)
286
298
  else
287
299
  rebuild_one(entry_class, definition, t)
@@ -420,13 +432,21 @@ module StandardLedger
420
432
  reflection.klass
421
433
  end
422
434
 
423
- # Refuse to rebuild for modes that don't yet implement the
424
- # log-replay path. `:inline`, `:sql`, and `:matview` are the supported
425
- # modes today; `:async` and `:trigger` land with their own mode PRs.
435
+ # All five projection modes (`:inline`, `:async`, `:sql`, `:matview`,
436
+ # `:trigger`) implement a log-replay path through this method.
437
+ #
438
+ # `:async` and `:inline` share the same per-target rebuild semantics —
439
+ # both delegate to `definition.projector_class.new.rebuild(target)`.
440
+ # The difference between the two modes is only in the after-create
441
+ # path (in-transaction vs. post-commit job), not in the rebuild path,
442
+ # which always runs synchronously.
426
443
  def validate_rebuildable_mode!(entry_class, definition)
427
444
  return if definition.mode == :inline
445
+ return if definition.mode == :async
446
+ return if definition.mode == :manual
428
447
  return if definition.mode == :sql
429
448
  return if definition.mode == :matview
449
+ return if definition.mode == :trigger
430
450
 
431
451
  raise StandardLedger::Error,
432
452
  "rebuild! does not yet support mode: #{definition.mode.inspect} " \
@@ -463,10 +483,12 @@ module StandardLedger
463
483
  # rebuildable should extract a `Projection` subclass and implement
464
484
  # `rebuild(target)`.
465
485
  def validate_rebuildable_projector!(entry_class, definition)
466
- # `:sql` mode carries its rebuild path in the recompute SQL itself —
467
- # no projector class is required (and `via:` is rejected at
468
- # registration). Skip the class-form preflight checks below.
486
+ # `:sql` and `:trigger` modes carry their rebuild path in the
487
+ # recompute / rebuild SQL itself — no projector class is required
488
+ # (and `via:` is rejected at registration). Skip the class-form
489
+ # preflight checks below.
469
490
  return if definition.mode == :sql
491
+ return if definition.mode == :trigger
470
492
 
471
493
  if definition.projector_class.nil?
472
494
  raise StandardLedger::NotRebuildable,
@@ -535,11 +557,12 @@ module StandardLedger
535
557
  )
536
558
  end
537
559
 
538
- # `:sql` mode rebuild path: run the same recompute SQL the
539
- # `after_create` callback runs, just bound to this target's id rather
540
- # than the entry's foreign key. The recompute SQL is the entire
541
- # contract for `:sql` projections there's no projector class to
542
- # invoke; the after-create and rebuild paths share one statement.
560
+ # `:sql` / `:trigger` mode rebuild path: run the recorded recompute
561
+ # SQL bound to this target's id. For `:sql` mode this is the same
562
+ # statement the `after_create` callback runs; for `:trigger` mode
563
+ # it's the rebuild SQL the host registered (the database trigger
564
+ # itself owns the after-INSERT path). Either way there's no
565
+ # projector class to invoke — the SQL is the entire contract.
543
566
  def rebuild_one_sql(entry_class, definition, target)
544
567
  target.class.transaction do
545
568
  sql = ActiveRecord::Base.sanitize_sql_array([ definition.recompute_sql, { target_id: target.id } ])
@@ -0,0 +1,60 @@
1
+ namespace :standard_ledger do
2
+ # PostgreSQL-only: queries `pg_trigger` directly. The only mode that
3
+ # registers a trigger today is `:trigger`, and the only adopter is
4
+ # nutripod-web (Postgres). SQLite has no comparable per-statement
5
+ # trigger introspection that makes sense for this gem's contract — if
6
+ # a non-Postgres host adopts `:trigger` mode in the future, they'll
7
+ # need to extend this task with a connection-adapter dispatch. The
8
+ # task is loud about its Postgres assumption: a `pg_trigger` query
9
+ # against a non-Postgres connection will raise, which is preferable
10
+ # to silently passing.
11
+ desc "Verify that every :trigger projection's trigger exists in the database (Postgres-only)"
12
+ task doctor: :environment do
13
+ require "standard_ledger"
14
+
15
+ # Discover entry classes by walking ActiveRecord descendants for
16
+ # ones that include `StandardLedger::Projector` and have at least
17
+ # one `:trigger` projection registered. The host's eager loading
18
+ # (Rails default in production / when explicitly invoked in dev)
19
+ # ensures all entry classes are loaded before this iterates.
20
+ entry_classes = ActiveRecord::Base.descendants.select { |klass|
21
+ klass.respond_to?(:standard_ledger_projections) &&
22
+ klass.standard_ledger_projections.any? { |d| d.mode == :trigger }
23
+ }
24
+
25
+ missing = []
26
+ entry_classes.each do |klass|
27
+ klass.standard_ledger_projections_for(:trigger).each do |definition|
28
+ # `pg_trigger.tgname` is the trigger's name as known to Postgres,
29
+ # but trigger names are scoped per-table — two tables can each
30
+ # have a trigger called e.g. `update_counts`. Join `pg_trigger`
31
+ # to `pg_class` and filter by the entry class's table name so the
32
+ # doctor reports presence on the *correct* table, not "anywhere
33
+ # in the schema". Use `klass.connection` (rather than
34
+ # `ActiveRecord::Base.connection`) so multi-DB setups query the
35
+ # connection that owns the entry class's table.
36
+ result = klass.connection.exec_query(
37
+ "SELECT 1 FROM pg_trigger t " \
38
+ "JOIN pg_class c ON c.oid = t.tgrelid " \
39
+ "WHERE t.tgname = $1 AND c.relname = $2 " \
40
+ "LIMIT 1",
41
+ "standard_ledger:doctor",
42
+ [ definition.trigger_name, klass.table_name ]
43
+ )
44
+ if result.rows.empty?
45
+ missing << " #{klass.name}##{definition.target_association}: trigger #{definition.trigger_name.inspect} not found"
46
+ end
47
+ end
48
+ end
49
+
50
+ if missing.empty?
51
+ puts "All :trigger projections have their triggers present."
52
+ else
53
+ warn "Missing triggers detected:"
54
+ missing.each { |line| warn line }
55
+ warn ""
56
+ warn "Run the migration that creates the trigger, or check that the trigger name in `projects_onto` matches the actual trigger name in the schema."
57
+ exit 1
58
+ end
59
+ end
60
+ end
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.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -122,10 +122,11 @@ dependencies:
122
122
  - !ruby/object:Gem::Version
123
123
  version: '0'
124
124
  description: StandardLedger captures the recurring 'append-only entry → N projection
125
- updates' pattern as a declarative DSL on host ActiveRecord models. Supports inline,
126
- sql, and matview projection modes (async and trigger modes land in subsequent releases);
127
- enforces idempotency-by-unique-index; and provides a deterministic rebuild path
128
- from the entry log.
125
+ updates' pattern as a declarative DSL on host ActiveRecord models. Supports five
126
+ projection modes :inline, :async, :sql, :matview, :trigger plus a deterministic
127
+ rebuild path from the entry log, ad-hoc materialized view refresh, and a doctor
128
+ rake task that verifies host-owned trigger presence. Enforces idempotency-by-unique-index
129
+ and immutability at the entry level.
129
130
  email:
130
131
  - code@jaryl.dev
131
132
  executables: []
@@ -145,9 +146,12 @@ files:
145
146
  - lib/standard_ledger/errors.rb
146
147
  - lib/standard_ledger/event_emitter.rb
147
148
  - lib/standard_ledger/jobs/matview_refresh_job.rb
149
+ - lib/standard_ledger/jobs/projection_job.rb
150
+ - lib/standard_ledger/modes/async.rb
148
151
  - lib/standard_ledger/modes/inline.rb
149
152
  - lib/standard_ledger/modes/matview.rb
150
153
  - lib/standard_ledger/modes/sql.rb
154
+ - lib/standard_ledger/modes/trigger.rb
151
155
  - lib/standard_ledger/projection.rb
152
156
  - lib/standard_ledger/projector.rb
153
157
  - lib/standard_ledger/result.rb
@@ -155,6 +159,7 @@ files:
155
159
  - lib/standard_ledger/rspec/helpers.rb
156
160
  - lib/standard_ledger/rspec/matchers.rb
157
161
  - lib/standard_ledger/version.rb
162
+ - lib/tasks/standard_ledger.rake
158
163
  homepage: https://github.com/rarebit-one/standard_ledger
159
164
  licenses:
160
165
  - MIT