hecks 1.1.0 → 1.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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/postgres/outbox.rb +7 -4
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +19 -0
- data/lib/hecks/adapters/driven/postgres.rb +20 -17
- data/lib/hecks/adapters/driven/sqlite/projection.rb +5 -2
- data/lib/hecks/bluebook/assembly/marks.rb +13 -3
- data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +10 -5
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +131 -16
- data/lib/hecks/ports/persistence/append_only.rb +11 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +54 -5
- data/lib/hecks/projections/glossary.rb +374 -0
- data/lib/hecks/projections.rb +1 -0
- data/lib/hecks/projector/cli_projector.rb +29 -13
- data/lib/hecks/projector/exporter.rb +24 -0
- data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
- data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
- data/lib/hecks/query_specification/common/order_by.rb +9 -2
- data/lib/hecks/query_specification/common/where_clause.rb +15 -2
- data/lib/hecks/runtime/interpreting.rb +16 -7
- data/lib/hecks/runtime/read_model_interpreter.rb +5 -4
- data/lib/hecks/runtime/value/coercion.rb +64 -1
- data/lib/hecks/version.rb +1 -1
- metadata +3 -1
|
@@ -2,6 +2,7 @@ require "json"
|
|
|
2
2
|
|
|
3
3
|
require_relative "../../../../adapters/driven/sql_query_builder"
|
|
4
4
|
require_relative "../../../../adapters/driven/postgres/outbox"
|
|
5
|
+
require_relative "../../../../adapters/driven/postgres/reconnect"
|
|
5
6
|
require_relative "postgres_era/lineage"
|
|
6
7
|
require_relative "postgres_era/lineage_manager"
|
|
7
8
|
require_relative "../../../../ports/persistence/append_only"
|
|
@@ -42,10 +43,19 @@ module Hecks
|
|
|
42
43
|
class PostgresEra
|
|
43
44
|
include SqlQueryBuilder
|
|
44
45
|
include Adapters::PostgresOutbox
|
|
46
|
+
include Adapters::PostgresReconnect
|
|
45
47
|
|
|
46
48
|
attr_reader :aggregate
|
|
47
49
|
|
|
48
|
-
|
|
50
|
+
# `:cross_process_lock` tells `Interpreting#run_dispatch_order_with_isolation`
|
|
51
|
+
# (runtime/interpreting.rb) this repository can hold a REAL
|
|
52
|
+
# cross-process lock for the whole dispatch order itself, via
|
|
53
|
+
# `with_write_lock` below — so it should use that instead of the
|
|
54
|
+
# in-process `AggregateLock` `Mutex` every other non-CAS repository
|
|
55
|
+
# falls back to. See ADR 0036: that in-process Mutex is invisible
|
|
56
|
+
# to `rust/host` dispatching against the same PostgresEra-bound
|
|
57
|
+
# tables from a separate OS process.
|
|
58
|
+
def persistence_capabilities = %i[atomic_put cross_process_lock]
|
|
49
59
|
|
|
50
60
|
# The capability idiom: only PostgresEra answers true, and only
|
|
51
61
|
# PostgresEra carries an era_check! for the boot gate to delegate to.
|
|
@@ -148,11 +158,35 @@ module Hecks
|
|
|
148
158
|
|
|
149
159
|
def initialize(aggregate:, settings: {}, root: nil)
|
|
150
160
|
@aggregate = aggregate
|
|
161
|
+
@settings = settings
|
|
151
162
|
@db = self.class.connect_for(aggregate.name, settings)
|
|
152
163
|
# The domain names the journal (one journal per lineage). The
|
|
153
|
-
# factory injects it
|
|
154
|
-
#
|
|
155
|
-
|
|
164
|
+
# factory injects it as the owning bluebook's own declared name
|
|
165
|
+
# (`RepositoryFactory.build`'s own `domain` positional arg —
|
|
166
|
+
# `bluebook.name`, confirmed at `outbox.rb`'s own call site —
|
|
167
|
+
# never this default in real dispatch). This default exists only
|
|
168
|
+
# for a directly-instantiated adapter (specs, consoles) that
|
|
169
|
+
# skips the factory.
|
|
170
|
+
#
|
|
171
|
+
# When the aggregate DOES have an owning chapter (`hecks_owner`
|
|
172
|
+
# set — true for any aggregate sealed through a real bluebook,
|
|
173
|
+
# even if this adapter itself was built by hand), default to the
|
|
174
|
+
# chapter's own declared PascalCase name. That's the same string
|
|
175
|
+
# `rust/host`'s `pg_advisory_xact_lock` key derives from
|
|
176
|
+
# (`HECKS_DOMAIN`, deploy-time-set to `world.domain`) — the
|
|
177
|
+
# aggregate's own snake_case `storage_name` silently disagreed
|
|
178
|
+
# with it (`hashtext` is byte-sensitive: `"chess"` and `"Chess"`
|
|
179
|
+
# never collide), which is ADR 0036 Blocker 1.
|
|
180
|
+
#
|
|
181
|
+
# When there's no owning chapter at all (a bare
|
|
182
|
+
# `AggregateBuilder` fixture built outside any real bluebook —
|
|
183
|
+
# `hecks_owner` is only ever stamped by chapter construction, see
|
|
184
|
+
# `traits.rb`'s `hecks_owner = self`) there is no chapter name to
|
|
185
|
+
# match Rust against in the first place, so fall back to the
|
|
186
|
+
# aggregate's own name, same as before this ADR.
|
|
187
|
+
@domain = self.class.setting(
|
|
188
|
+
settings, :domain, default: aggregate.hecks_owner&.name || aggregate.storage_name
|
|
189
|
+
).to_s
|
|
156
190
|
@lineage = Lineage.new(@db, @domain)
|
|
157
191
|
@lineage.ensure_base!
|
|
158
192
|
# The era gate resolves which era this boot IS (an old checkout
|
|
@@ -274,6 +308,18 @@ module Hecks
|
|
|
274
308
|
head_phase(declared, uncached, ids, args)
|
|
275
309
|
end
|
|
276
310
|
|
|
311
|
+
# ADR 0036's actual fix — see `persistence_capabilities` above.
|
|
312
|
+
# Wraps the WHOLE dispatch order (hydrate through save), not just
|
|
313
|
+
# `append`'s own transaction below: `lock_writes!` has to be held
|
|
314
|
+
# before hydrate even starts, or two cross-process writers can
|
|
315
|
+
# both hydrate unlocked and race for the write, each blind to the
|
|
316
|
+
# other. `transaction` (via `include Adapters::PostgresOutbox`) is
|
|
317
|
+
# already re-entrant — `append`/`atomic_put`'s own inner
|
|
318
|
+
# `transaction do ... end`, deep inside the block below, joins
|
|
319
|
+
# this SAME transaction instead of opening/committing its own, so
|
|
320
|
+
# the advisory lock stays held until this whole block returns.
|
|
321
|
+
def with_write_lock(&block) = transaction { lock_writes!; block.call } # rubocop:disable Style/Semicolon
|
|
322
|
+
|
|
277
323
|
# HELD FOR THE WHOLE TRANSACTION, not just around the INSERT — the
|
|
278
324
|
# ordinal is assigned by the column's own `nextval()` default, inside
|
|
279
325
|
# this same statement, so the lock has to already be held before that
|
|
@@ -290,7 +336,10 @@ module Hecks
|
|
|
290
336
|
# still runs, cheaply, during AppendOnly#recover!'s full replay on
|
|
291
337
|
# every boot (see `project` below), and a second write there would
|
|
292
338
|
# make that replay pay real DB cost for a snapshot that's already
|
|
293
|
-
# correct.
|
|
339
|
+
# correct. When `with_write_lock` above already drove this whole
|
|
340
|
+
# dispatch, `lock_writes!` here is a harmless re-acquire of the
|
|
341
|
+
# same already-held (per-session-reentrant) advisory lock; a bare
|
|
342
|
+
# `repository.save` outside a full dispatch still takes it fresh.
|
|
294
343
|
def append(entry)
|
|
295
344
|
transaction do
|
|
296
345
|
lock_writes!
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
require_relative "../projector"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Projections
|
|
5
|
+
# A CHAPTER, PROJECTED AS ITS UBIQUITOUS LANGUAGE — one glossary
|
|
6
|
+
# entry per term the business actually uses: the Domain itself, and
|
|
7
|
+
# every Aggregate, Entity, Value Object, Command (a verb), Query (a
|
|
8
|
+
# question), Read Model, Event, Role, Lifecycle, Saga, and Policy it
|
|
9
|
+
# declares — every BUSINESS-domain construct the language's own §30
|
|
10
|
+
# reference distinguishes. Deliberately not the deployment/wiring
|
|
11
|
+
# half (World, Hecksagon, Port, Adapter, Translation): Evans'
|
|
12
|
+
# Ubiquitous Language is the business model, not the plumbing that
|
|
13
|
+
# runs it.
|
|
14
|
+
#
|
|
15
|
+
# GROUPED UNDER THE AGGREGATE EACH TERM BELONGS TO — a reader thinks
|
|
16
|
+
# "what does Account mean" before they think "what starts with A",
|
|
17
|
+
# so a term's home is the aggregate that declares it (an Entity's
|
|
18
|
+
# own Commands/Queries/Lifecycle nest under ITS aggregate, not
|
|
19
|
+
# listed as their own peer); an Event or a Policy that reacts to one
|
|
20
|
+
# nests under the aggregate whose command actually raises it — the
|
|
21
|
+
# same "who does this" a reader would ask by hand. Only what
|
|
22
|
+
# genuinely belongs to no single aggregate (a Role, issued across
|
|
23
|
+
# several; a Read Model, joined across several) gets its own
|
|
24
|
+
# trailing section. Within a section, still one short sentence per
|
|
25
|
+
# term, dictionary-plain — this is not `DocsProjector` re-sorted
|
|
26
|
+
# (verbs, refusals, wire shapes, walked for a caller); it is what
|
|
27
|
+
# each term MEANS, walked for a domain expert.
|
|
28
|
+
#
|
|
29
|
+
# WHAT IT DELIBERATELY DOES NOT DO: invent, same discipline as
|
|
30
|
+
# `DocsProjector`. A term with no declared prose (most value objects;
|
|
31
|
+
# every event and role, which the language only ever spells as bare
|
|
32
|
+
# strings — a command's `emits:`/`role:`) gets a definition DERIVED
|
|
33
|
+
# from the graph around it — what it's shaped like, what emits it,
|
|
34
|
+
# who issues it — never a restated name standing in for a sentence
|
|
35
|
+
# nobody wrote.
|
|
36
|
+
#
|
|
37
|
+
# Projector.call(:glossary, bluebook: <the Bluebook chapter>)
|
|
38
|
+
module Glossary
|
|
39
|
+
extend Projector::Target
|
|
40
|
+
|
|
41
|
+
projects_as :glossary
|
|
42
|
+
|
|
43
|
+
Entry = Struct.new(:term, :kind, :definition, :within, :section, keyword_init: true)
|
|
44
|
+
|
|
45
|
+
# DISPLAY ORDER WITHIN A SECTION — the aggregate's own row first
|
|
46
|
+
# (what it IS), then the shapes it can be in and hold, then what
|
|
47
|
+
# you can ask of it, then what happens on its own.
|
|
48
|
+
KIND_RANK = {
|
|
49
|
+
"Aggregate" => 0, "Lifecycle" => 1, "Entity" => 2, "Value Object" => 3,
|
|
50
|
+
"Command" => 4, "Query" => 5, "Event" => 6, "Policy" => 7, "Saga" => 8
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
ROLES_SECTION = "Roles".freeze
|
|
54
|
+
READ_MODELS_SECTION = "Read Models".freeze
|
|
55
|
+
|
|
56
|
+
module_function
|
|
57
|
+
|
|
58
|
+
def call(bluebook:, options: {}) = render(bluebook)
|
|
59
|
+
|
|
60
|
+
# ── gathering ─────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
# AN ENTITY CAN CARRY ITS OWN COMMANDS, QUERIES, AND VALUE OBJECTS
|
|
63
|
+
# TOO — walked the same one level down `DocsProjector` and
|
|
64
|
+
# `Projections::Diagrams` already walk it, so a domain's entity
|
|
65
|
+
# gaining any of these needs no change here either.
|
|
66
|
+
def holders(bluebook)
|
|
67
|
+
bluebook.aggregates.flat_map { |aggregate| [aggregate, *aggregate.entities] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# EVERY HOLDER'S OWN AGGREGATE, ONE HOP OR ZERO — an aggregate
|
|
71
|
+
# maps to itself, an entity to whichever aggregate declared it.
|
|
72
|
+
# This is the single fact the whole grouping is built on: a
|
|
73
|
+
# Command/Query/Lifecycle's `within` already names its holder, so
|
|
74
|
+
# looking that name up here is enough to find its SECTION even
|
|
75
|
+
# when the holder is an entity two levels down from the chapter.
|
|
76
|
+
def holder_aggregate(bluebook)
|
|
77
|
+
bluebook.aggregates.each_with_object({}) do |aggregate, map|
|
|
78
|
+
map[aggregate.hecks_name] = aggregate.hecks_name
|
|
79
|
+
aggregate.entities.each { |entity| map[entity.hecks_name] = aggregate.hecks_name }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# EVERY EVENT'S OWN AGGREGATE — an event is never declared, only
|
|
84
|
+
# emitted, so its home is whichever aggregate the FIRST command
|
|
85
|
+
# that raises it belongs to. A policy or saga reacting to that
|
|
86
|
+
# event inherits the same home, the same way a reader tracing the
|
|
87
|
+
# reaction by hand would: "what raises this, and whose is that."
|
|
88
|
+
def event_aggregate(bluebook, holder_aggregate)
|
|
89
|
+
map = {}
|
|
90
|
+
holders(bluebook).each do |holder|
|
|
91
|
+
home = holder_aggregate[holder.hecks_name]
|
|
92
|
+
holder.commands.each { |command| command.emits.each { |event| map[event] ||= home } }
|
|
93
|
+
end
|
|
94
|
+
map
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def entries(bluebook)
|
|
98
|
+
holder_agg = holder_aggregate(bluebook)
|
|
99
|
+
event_agg = event_aggregate(bluebook, holder_agg)
|
|
100
|
+
|
|
101
|
+
entries = []
|
|
102
|
+
entries += domain_entries(bluebook)
|
|
103
|
+
entries += aggregate_entries(bluebook)
|
|
104
|
+
entries += entity_entries(bluebook, holder_agg)
|
|
105
|
+
entries += value_object_entries(bluebook, holder_agg)
|
|
106
|
+
entries += command_entries(bluebook, holder_agg)
|
|
107
|
+
entries += query_entries(bluebook, holder_agg)
|
|
108
|
+
entries += read_model_entries(bluebook)
|
|
109
|
+
entries += event_entries(bluebook, event_agg)
|
|
110
|
+
entries += role_entries(bluebook)
|
|
111
|
+
entries += lifecycle_entries(bluebook, holder_agg)
|
|
112
|
+
entries += saga_entries(bluebook, event_agg)
|
|
113
|
+
entries += policy_entries(bluebook, event_agg)
|
|
114
|
+
entries
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# THE CHAPTER ITSELF, AS A TERM — the one construct every other
|
|
118
|
+
# entry here lives inside (`Hecks.bluebook "Banking" do ... end`),
|
|
119
|
+
# and the one place `vision` — the single sentence written for a
|
|
120
|
+
# reader who doesn't know the domain yet — actually lives. Given
|
|
121
|
+
# no `section` (rendered once, at the very top, above every
|
|
122
|
+
# aggregate) since it is the one term that belongs to no aggregate
|
|
123
|
+
# by being the thing that holds them all.
|
|
124
|
+
def domain_entries(bluebook)
|
|
125
|
+
[Entry.new(term: bluebook.name, kind: "Domain", definition: bluebook.vision)]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def aggregate_entries(bluebook)
|
|
129
|
+
bluebook.aggregates.map do |aggregate|
|
|
130
|
+
Entry.new(term: aggregate.hecks_name, kind: "Aggregate", definition: aggregate.description,
|
|
131
|
+
section: aggregate.hecks_name)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def entity_entries(bluebook, holder_agg)
|
|
136
|
+
bluebook.aggregates.flat_map do |aggregate|
|
|
137
|
+
aggregate.entities.map do |entity|
|
|
138
|
+
Entry.new(term: entity.hecks_name, kind: "Entity", within: aggregate.hecks_name,
|
|
139
|
+
section: holder_agg[aggregate.hecks_name], definition: entity.description)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# A VALUE OBJECT CARRIES NO `description` — the language never gave
|
|
145
|
+
# it one (`Bluebook::ValueObject` declares `attributes`,
|
|
146
|
+
# `invariants`, `members`, `closed_set`, nothing else). Its
|
|
147
|
+
# definition is derived from its own shape instead: a closed set
|
|
148
|
+
# states its members, an open one its fields — the same
|
|
149
|
+
# distinction `DocsProjector#shape_of` draws.
|
|
150
|
+
#
|
|
151
|
+
# AGGREGATES ONLY, not `holders` — unlike commands and queries, an
|
|
152
|
+
# entity declares no value objects of its own (`Bluebook::Entity`
|
|
153
|
+
# deliberately does not answer `value_objects`; its argument types
|
|
154
|
+
# live on the aggregate above it, same as `DocsProjector#value_object_for`
|
|
155
|
+
# already has to account for).
|
|
156
|
+
def value_object_entries(bluebook, holder_agg)
|
|
157
|
+
bluebook.aggregates.flat_map do |aggregate|
|
|
158
|
+
aggregate.value_objects.map do |vo|
|
|
159
|
+
Entry.new(term: vo.hecks_name, kind: "Value Object", within: aggregate.hecks_name,
|
|
160
|
+
section: holder_agg[aggregate.hecks_name], definition: value_object_definition(vo))
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# THE SHAPE, THEN THE RULES — a value object's own `invariant`
|
|
166
|
+
# bodies are real authored prose ("a currency is a three-letter
|
|
167
|
+
# code"), not derived the way the shape above is, and were
|
|
168
|
+
# sitting unread by this projector until now: `to_h`'s own
|
|
169
|
+
# `invariants` field already carries them, one `description` per
|
|
170
|
+
# `Bluebook::Invariant`.
|
|
171
|
+
def value_object_definition(value_object)
|
|
172
|
+
sentence = value_object_shape(value_object)
|
|
173
|
+
rules = value_object.invariants.map(&:description)
|
|
174
|
+
sentence += " Must satisfy: #{rules.join('; ')}." unless rules.empty?
|
|
175
|
+
sentence
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# A CLOSED SET'S ROWS, not just their first field — the same
|
|
179
|
+
# correction `Projections::Vocabulary` already had to make
|
|
180
|
+
# (`StatementFrequency` names retention months and a paper fee
|
|
181
|
+
# alongside its cadence; flattening every field into one list
|
|
182
|
+
# produces well-formed nonsense, same as it did there).
|
|
183
|
+
def value_object_shape(value_object)
|
|
184
|
+
if value_object.closed_set?
|
|
185
|
+
if value_object.members.first && value_object.members.first.size > 1
|
|
186
|
+
rows = value_object.members.map { |row| "{ #{row.map { |f, v| "#{f}: #{v.inspect}" }.join(', ')} }" }
|
|
187
|
+
"One of: #{rows.join('; ')}."
|
|
188
|
+
else
|
|
189
|
+
"One of #{value_object.members.flat_map(&:values).uniq.map { |m| "`#{m}`" }.join(', ')}."
|
|
190
|
+
end
|
|
191
|
+
elsif value_object.attributes.empty?
|
|
192
|
+
"A marker with no fields of its own."
|
|
193
|
+
else
|
|
194
|
+
"{ #{value_object.attributes.map { |f| "#{f.name}: #{f.type}" }.join(', ')} }"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def command_entries(bluebook, holder_agg)
|
|
199
|
+
holders(bluebook).flat_map do |holder|
|
|
200
|
+
holder.commands.map do |command|
|
|
201
|
+
Entry.new(term: command.hecks_name, kind: "Command", within: holder.hecks_name,
|
|
202
|
+
section: holder_agg[holder.hecks_name], definition: command.goal)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def query_entries(bluebook, holder_agg)
|
|
208
|
+
holders(bluebook).flat_map do |holder|
|
|
209
|
+
holder.queries.map do |query|
|
|
210
|
+
Entry.new(term: query.hecks_name, kind: "Query", within: holder.hecks_name,
|
|
211
|
+
section: holder_agg[holder.hecks_name], definition: query.description)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# CROSS-AGGREGATE, ON PURPOSE — a read model joins heads from more
|
|
217
|
+
# than one aggregate (`ReadModel`'s own header: "an ask that
|
|
218
|
+
# gathers heads from more than one aggregate"), so it belongs to
|
|
219
|
+
# no single one and gets its own trailing section instead.
|
|
220
|
+
def read_model_entries(bluebook)
|
|
221
|
+
bluebook.read_models.map do |read_model|
|
|
222
|
+
Entry.new(term: read_model.name, kind: "Read Model", section: READ_MODELS_SECTION,
|
|
223
|
+
definition: read_model.description)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# AN EVENT IS NEVER ITS OWN DECLARATION — the language only ever
|
|
228
|
+
# spells one as a bare string in a command's `emits:`. So it earns
|
|
229
|
+
# a glossary entry by appearing there, and its "definition" is the
|
|
230
|
+
# one fact the graph actually holds about it: which verb(s) raise
|
|
231
|
+
# it, and — the other half nothing raises without a reader
|
|
232
|
+
# crossing over to check `bluebook.policies` by hand — which
|
|
233
|
+
# policy (if any) reacts to it. That is derived, not invented —
|
|
234
|
+
# nobody wrote a sentence this restates.
|
|
235
|
+
def event_entries(bluebook, event_agg)
|
|
236
|
+
by_event = Hash.new { |h, k| h[k] = [] }
|
|
237
|
+
holders(bluebook).each do |holder|
|
|
238
|
+
holder.commands.each do |command|
|
|
239
|
+
command.emits.each { |event| by_event[event] << command.hecks_name }
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
by_event.map do |event, commands|
|
|
243
|
+
sentence = "Raised by #{commands.uniq.map { |c| "`#{c}`" }.join(', ')}."
|
|
244
|
+
reactions = policies_on(bluebook, event)
|
|
245
|
+
sentence += " #{reactions.join(' ')}" unless reactions.empty?
|
|
246
|
+
Entry.new(term: event, kind: "Event", section: event_agg[event], definition: sentence)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# A POLICY NAMES ITS EVENT QUALIFIED (`"Account.AccountFrozen"`) —
|
|
251
|
+
# `bare` strips the aggregate a policy's own `on_event`/
|
|
252
|
+
# `trigger_command` always carries but an event's own glossary
|
|
253
|
+
# term (keyed off `emits:`, which is never qualified) never does.
|
|
254
|
+
def bare(qualified) = qualified.to_s.split(".").last
|
|
255
|
+
|
|
256
|
+
def policies_on(bluebook, event)
|
|
257
|
+
bluebook.policies.select { |policy| bare(policy.on_event) == event }.map do |policy|
|
|
258
|
+
domain = " in #{policy.target_domain}" if policy.target_domain
|
|
259
|
+
"Triggers policy `#{policy.name}` → `#{policy.trigger_command}`#{domain}."
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# A ROLE IS THE SAME SHAPE OF FACT AS AN EVENT — free text on a
|
|
264
|
+
# command's `role:`, never declared on its own — so it gets the
|
|
265
|
+
# same treatment: who it is, told by what it does. CROSS-CUTTING
|
|
266
|
+
# BY NATURE (`System`/`Customer` issue commands across half the
|
|
267
|
+
# aggregates in this corpus), so — like a Read Model — it belongs
|
|
268
|
+
# to no single aggregate.
|
|
269
|
+
def role_entries(bluebook)
|
|
270
|
+
by_role = Hash.new { |h, k| h[k] = [] }
|
|
271
|
+
holders(bluebook).each do |holder|
|
|
272
|
+
holder.commands.select(&:role).each { |command| by_role[command.role] << command.hecks_name }
|
|
273
|
+
end
|
|
274
|
+
by_role.map do |role, commands|
|
|
275
|
+
Entry.new(term: role, kind: "Role", section: ROLES_SECTION,
|
|
276
|
+
definition: "Issues #{commands.uniq.map { |c| "`#{c}`" }.join(', ')}.")
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# A LIFECYCLE HAS NO NAME OF ITS OWN — it is a body nested inside
|
|
281
|
+
# the aggregate or entity it governs (`lifecycle :status do ...
|
|
282
|
+
# end`), so it earns a glossary entry under ITS HOLDER's term,
|
|
283
|
+
# the same way `Command`/`Query` do — a reader who looks up
|
|
284
|
+
# `Account` and a reader who looks up what states an Account can
|
|
285
|
+
# hold are asking two different questions of the same name.
|
|
286
|
+
def lifecycle_entries(bluebook, holder_agg)
|
|
287
|
+
holders(bluebook).select(&:lifecycle).map do |holder|
|
|
288
|
+
lifecycle = holder.lifecycle
|
|
289
|
+
states = ([lifecycle.default] + lifecycle.transitions.map { |_name, transition| transition.target }).uniq
|
|
290
|
+
Entry.new(term: holder.hecks_name, kind: "Lifecycle", section: holder_agg[holder.hecks_name],
|
|
291
|
+
definition: "Starts at `#{lifecycle.default}`. States: #{states.map { |s| "`#{s}`" }.join(', ')}.")
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# A SAGA'S OWN HANDLERS/DISPATCHES ARE NAMELESS TOO — the same
|
|
296
|
+
# shape as a Lifecycle's transitions, one level up: a sequence of
|
|
297
|
+
# states no individual step is worth its own glossary row for.
|
|
298
|
+
# `states`, from the builder's own precomputed leg order
|
|
299
|
+
# (`Behaviour::ProcessManager#saga`, the same field
|
|
300
|
+
# `DocsProjector#closing` already reads), says what a Lifecycle
|
|
301
|
+
# entry says for an aggregate — the shape of the whole journey,
|
|
302
|
+
# not just where it starts and ends. HOMED ON ITS STARTING EVENT —
|
|
303
|
+
# a saga usually ends somewhere else entirely (that is most of
|
|
304
|
+
# what makes it a saga), so "where it begins" is the only single
|
|
305
|
+
# aggregate honestly its own.
|
|
306
|
+
def saga_entries(bluebook, event_agg)
|
|
307
|
+
bluebook.process_managers.map do |saga|
|
|
308
|
+
shape = saga.to_h
|
|
309
|
+
sentence = "Starts on `#{shape[:starts_on]}`, ends on `#{shape[:ends_on]}`."
|
|
310
|
+
states = Array(shape[:states])
|
|
311
|
+
sentence += " States: #{states.map { |s| "`#{s}`" }.join(' → ')}." unless states.empty?
|
|
312
|
+
Entry.new(term: shape[:name], kind: "Saga", section: event_agg[bare(shape[:starts_on])],
|
|
313
|
+
definition: sentence)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# A POLICY IS A NAMED CONSTRUCT TOO (`policy "ReviewOnFreeze" do
|
|
318
|
+
# ... end`), the same as a Saga — so it earns its own glossary
|
|
319
|
+
# entry rather than surfacing only as a parenthetical on the
|
|
320
|
+
# event that triggers it. HOMED ON THE EVENT IT REACTS TO, not
|
|
321
|
+
# what it dispatches — `on_event` is always local to this
|
|
322
|
+
# bluebook, `trigger_command` sometimes is not (a cross-domain
|
|
323
|
+
# policy's own `across`). `for_each`, when declared, fans the
|
|
324
|
+
# dispatch out over more than one record; stated here because a
|
|
325
|
+
# reader who only sees "dispatches X" would otherwise assume one.
|
|
326
|
+
def policy_entries(bluebook, event_agg)
|
|
327
|
+
bluebook.policies.map do |policy|
|
|
328
|
+
sentence = "On `#{bare(policy.on_event)}`, dispatches `#{policy.trigger_command}`"
|
|
329
|
+
sentence += " in #{policy.target_domain}" if policy.target_domain
|
|
330
|
+
sentence += ", once per `#{policy.for_each}` row" if policy.for_each
|
|
331
|
+
Entry.new(term: policy.name, kind: "Policy", section: event_agg[bare(policy.on_event)],
|
|
332
|
+
definition: "#{sentence}.")
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# ── rendering ─────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
def render(bluebook)
|
|
339
|
+
all = entries(bluebook)
|
|
340
|
+
grouped = all.reject { |entry| entry.kind == "Domain" }.group_by { |entry| entry.section || "Cross-Domain Reactions" }
|
|
341
|
+
|
|
342
|
+
out = [chapter_header(bluebook)]
|
|
343
|
+
bluebook.aggregates.map(&:hecks_name).sort.each { |name| out << section(name, grouped[name]) if grouped[name] }
|
|
344
|
+
[ROLES_SECTION, READ_MODELS_SECTION, "Cross-Domain Reactions"].each do |name|
|
|
345
|
+
out << section(name, grouped[name]) if grouped[name]
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
"#{out.compact.join("\n\n")}\n"
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def chapter_header(bluebook)
|
|
352
|
+
out = ["# #{bluebook.name} — Glossary", ""]
|
|
353
|
+
out += ["> #{bluebook.vision}", ""] if bluebook.vision
|
|
354
|
+
out << "The ubiquitous language: every term #{bluebook.name} declares, grouped under the aggregate " \
|
|
355
|
+
"it belongs to, in the domain's own words. Generated from `#{bluebook.name}`'s bluebook — a " \
|
|
356
|
+
"term missing here is a term the bluebook does not yet declare, and a definition missing here " \
|
|
357
|
+
"is a sentence nobody has written yet."
|
|
358
|
+
out.join("\n")
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def section(title, rows)
|
|
362
|
+
return nil unless rows
|
|
363
|
+
|
|
364
|
+
sorted = rows.sort_by { |entry| [KIND_RANK[entry.kind] || 99, entry.term.downcase] }
|
|
365
|
+
"## #{title}\n\n| Term | Kind | Definition |\n|---|---|---|\n#{sorted.map { |entry| entry_row(entry) }.join("\n")}"
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def entry_row(entry)
|
|
369
|
+
term = entry.within ? "**#{entry.term}** *(#{entry.within})*" : "**#{entry.term}**"
|
|
370
|
+
"| #{term} | #{entry.kind} | #{entry.definition || '—'} |"
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
data/lib/hecks/projections.rb
CHANGED
|
@@ -156,6 +156,33 @@ module Hecks
|
|
|
156
156
|
entity ? "#{entity.hecks_name}." : "", verb.hecks_name].join
|
|
157
157
|
end
|
|
158
158
|
|
|
159
|
+
# THE ARGUMENTS A RECEIVER ADDS, BEFORE ANY VERB-SPECIFIC ONE. Shared by
|
|
160
|
+
# `command_spec` and `port_spec` — a port operation always addresses an
|
|
161
|
+
# aggregate record (`port_spec` passes `receiver: :aggregate`, never
|
|
162
|
+
# `:entity` or `nil`, because a port is declared on an aggregate, never
|
|
163
|
+
# an entity), and this is exactly the same "how do I name the record"
|
|
164
|
+
# question a command with a receiver already answers, so it is answered
|
|
165
|
+
# once, here, rather than duplicated.
|
|
166
|
+
#
|
|
167
|
+
# `nil` — a creating command — takes none: there is no existing record
|
|
168
|
+
# yet for `to=` to name.
|
|
169
|
+
def receiver_options(receiver, aggregate, entity)
|
|
170
|
+
case receiver
|
|
171
|
+
when :entity
|
|
172
|
+
[
|
|
173
|
+
{ path: "to.aggregate", type: "String", required: true,
|
|
174
|
+
note: "id of the #{aggregate.hecks_name} holding the #{entity.hecks_name}" },
|
|
175
|
+
{ path: "to.entity", type: "String", required: true,
|
|
176
|
+
note: "id of the #{entity.hecks_name} to act on" }
|
|
177
|
+
]
|
|
178
|
+
when :aggregate
|
|
179
|
+
[{ path: "to", type: "String", required: true,
|
|
180
|
+
note: "id of the #{aggregate.hecks_name} to act on" }]
|
|
181
|
+
else
|
|
182
|
+
[]
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
159
186
|
# ── one verb ──────────────────────────────────────────────────────
|
|
160
187
|
|
|
161
188
|
def command_spec(bluebook, aggregate, entity, command)
|
|
@@ -166,7 +193,6 @@ module Hecks
|
|
|
166
193
|
else
|
|
167
194
|
(command.creates? ? nil : :aggregate)
|
|
168
195
|
end
|
|
169
|
-
legacy_arguments = []
|
|
170
196
|
|
|
171
197
|
# THE RECEIVER IS NOT A COMMAND ARGUMENT. An aggregate command names
|
|
172
198
|
# its record through to; an entity command needs both the aggregate
|
|
@@ -178,18 +204,8 @@ module Hecks
|
|
|
178
204
|
# Existing aggregate scripts may still spell the receiver id=... .
|
|
179
205
|
# That alias is deliberately hidden from help and recorded separately
|
|
180
206
|
# as legacy_arguments; new help and examples teach only to=... .
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
{ path: "to.aggregate", type: "String", required: true,
|
|
184
|
-
note: "id of the #{aggregate.hecks_name} holding the #{entity.hecks_name}" },
|
|
185
|
-
{ path: "to.entity", type: "String", required: true,
|
|
186
|
-
note: "id of the #{entity.hecks_name} to act on" }
|
|
187
|
-
] + arguments
|
|
188
|
-
elsif receiver == :aggregate
|
|
189
|
-
arguments = [{ path: "to", type: "String", required: true,
|
|
190
|
-
note: "id of the #{aggregate.hecks_name} to act on" }] + arguments
|
|
191
|
-
legacy_arguments = [{ path: "id", type: "String", required: true }]
|
|
192
|
-
end
|
|
207
|
+
arguments = receiver_options(receiver, aggregate, entity) + arguments
|
|
208
|
+
legacy_arguments = receiver == :aggregate ? [{ path: "id", type: "String", required: true }] : []
|
|
193
209
|
|
|
194
210
|
{ verb: fqn(bluebook, aggregate, command, entity), kind: :command,
|
|
195
211
|
summary: command.goal, role: command.role, role_gated: !command.role.to_s.empty?,
|
|
@@ -54,6 +54,30 @@ module Hecks
|
|
|
54
54
|
{ capable_aggregates: capable.map { |aggregate| { name: aggregate.name, storage_name: aggregate.storage_name } } }
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# A BINDING fact, same shape/reasoning as `lineage` above: every
|
|
58
|
+
# aggregate's DECLARED persistence adapter name (`persisted_by`),
|
|
59
|
+
# not part of the canonical bluebook shape `call` exports (ADR
|
|
60
|
+
# 0001 — the IR describes what's declared, never which adapter a
|
|
61
|
+
# deployment binds it to). Unlike `lineage`, this needs no era
|
|
62
|
+
# plugin — `BindingPolicy` is core, always loaded — and covers
|
|
63
|
+
# EVERY aggregate, not just lineage-capable ones: `rust/host`
|
|
64
|
+
# (`ir.rs`'s own `refuse_unsupported_persistence_adapters`) reads
|
|
65
|
+
# this to refuse loudly, at boot, against a domain bound to an
|
|
66
|
+
# adapter it has no backend for (Heki, Memory, Sqlite, D1,
|
|
67
|
+
# LocalStorage — `rust/host` understands only Postgres/PostgresEra
|
|
68
|
+
# today), rather than silently building up a second, disjoint
|
|
69
|
+
# history nothing but Rust ever reads while the real state stays
|
|
70
|
+
# wherever its own adapter actually wrote it.
|
|
71
|
+
def persistence(registry, domain_name)
|
|
72
|
+
bluebook = registry.bluebooks.fetch(domain_name)
|
|
73
|
+
aggregates = bluebook.aggregates.map do |aggregate|
|
|
74
|
+
adapter = Ports::Persistence::BindingPolicy.resolve(registry, domain_name, aggregate).adapter
|
|
75
|
+
{ name: aggregate.name, storage_name: aggregate.storage_name, adapter: adapter }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
{ aggregates: aggregates }
|
|
79
|
+
end
|
|
80
|
+
|
|
57
81
|
# Translation IR, always as an array, WITH each aggregate's
|
|
58
82
|
# precompiled SQL attached (`compiled_translation_aggregate`) —
|
|
59
83
|
# this is the export a consumer embeds (`ir.json`'s `translations`
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
module Hecks
|
|
2
2
|
module QuerySpecification
|
|
3
3
|
module Common
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# `target` — see `WhereClause`'s own header (ADR 0055): which
|
|
5
|
+
# many-side `include`d aggregate this `limit` applies to, on a
|
|
6
|
+
# `read_model` declaring more than one. `nil` for a plain `Query`,
|
|
7
|
+
# and for a `read_model` with a single many-side head.
|
|
8
|
+
LimitSpec = Struct.new(:value, :target, keyword_init: true) do
|
|
9
|
+
def to_h
|
|
10
|
+
base = { value: QuerySpecification.render_value(value) }
|
|
11
|
+
target ? base.merge(target: target.to_s) : base
|
|
12
|
+
end
|
|
6
13
|
end
|
|
7
14
|
end
|
|
8
15
|
end
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
module Hecks
|
|
2
2
|
module QuerySpecification
|
|
3
3
|
module Common
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# `target` — see `WhereClause`'s own header (ADR 0055): which
|
|
5
|
+
# many-side `include`d aggregate this `offset` applies to, on a
|
|
6
|
+
# `read_model` declaring more than one. `nil` for a plain `Query`,
|
|
7
|
+
# and for a `read_model` with a single many-side head.
|
|
8
|
+
OffsetSpec = Struct.new(:value, :target, keyword_init: true) do
|
|
9
|
+
def to_h
|
|
10
|
+
base = { value: QuerySpecification.render_value(value) }
|
|
11
|
+
target ? base.merge(target: target.to_s) : base
|
|
12
|
+
end
|
|
6
13
|
end
|
|
7
14
|
end
|
|
8
15
|
end
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
module Hecks
|
|
2
2
|
module QuerySpecification
|
|
3
3
|
module Common
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# `target` — see `WhereClause`'s own header (ADR 0055): which
|
|
5
|
+
# many-side `include`d aggregate this `order_by` applies to, on a
|
|
6
|
+
# `read_model` declaring more than one. `nil` for a plain `Query`,
|
|
7
|
+
# and for a `read_model` with a single many-side head.
|
|
8
|
+
OrderBy = Struct.new(:field, :direction, :target, keyword_init: true) do
|
|
9
|
+
def to_h
|
|
10
|
+
base = { field: field.to_s, direction: direction.to_s }
|
|
11
|
+
target ? base.merge(target: target.to_s) : base
|
|
12
|
+
end
|
|
6
13
|
end
|
|
7
14
|
end
|
|
8
15
|
end
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
module Hecks
|
|
2
2
|
module QuerySpecification
|
|
3
3
|
module Common
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# `target` — which many-side `include`d aggregate this clause applies
|
|
5
|
+
# to on a `read_model` with more than one (ADR 0055's own `on:`,
|
|
6
|
+
# `ReadModelBuilder#where_impl`). Always `nil` for a plain `Query`'s
|
|
7
|
+
# own `where` (that builder never overrides `where_impl` to accept
|
|
8
|
+
# `on:`), and `nil` for a `read_model` with a single many-side head,
|
|
9
|
+
# where naming one is unnecessary. `to_h` omits the key entirely
|
|
10
|
+
# rather than emitting `target: nil` — the same "absent, not null"
|
|
11
|
+
# convention `count`/`median_field` already established
|
|
12
|
+
# (`lib/hecks/bluebook/read_model.rb`), so every read model that
|
|
13
|
+
# never uses `on:` keeps its existing wire shape byte-identical.
|
|
14
|
+
WhereClause = Struct.new(:field, :op, :value, :target, keyword_init: true) do
|
|
15
|
+
def to_h
|
|
16
|
+
base = { field: field.to_s, op: op.to_s, value: QuerySpecification.render_value(value) }
|
|
17
|
+
target ? base.merge(target: target.to_s) : base
|
|
18
|
+
end
|
|
6
19
|
end
|
|
7
20
|
end
|
|
8
21
|
end
|