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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +170 -1
- data/README.md +102 -4
- data/lib/standard_ledger/config.rb +3 -2
- data/lib/standard_ledger/engine.rb +7 -0
- data/lib/standard_ledger/entry.rb +99 -10
- data/lib/standard_ledger/errors.rb +10 -0
- data/lib/standard_ledger/jobs/projection_job.rb +94 -0
- data/lib/standard_ledger/modes/async.rb +141 -0
- data/lib/standard_ledger/modes/matview.rb +35 -4
- data/lib/standard_ledger/modes/trigger.rb +50 -0
- data/lib/standard_ledger/projector.rb +279 -2
- data/lib/standard_ledger/version.rb +1 -1
- data/lib/standard_ledger.rb +38 -15
- data/lib/tasks/standard_ledger.rake +60 -0
- metadata +10 -5
data/lib/standard_ledger.rb
CHANGED
|
@@ -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
|
-
# - `:
|
|
201
|
-
#
|
|
202
|
-
#
|
|
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
|
-
#
|
|
424
|
-
# log-replay path
|
|
425
|
-
#
|
|
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`
|
|
467
|
-
# no projector class is required
|
|
468
|
-
# registration). Skip the class-form
|
|
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
|
|
539
|
-
#
|
|
540
|
-
#
|
|
541
|
-
#
|
|
542
|
-
#
|
|
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.
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|