hecks 1.1.0 → 1.3.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/heki/journal.rb +57 -0
- 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/postgres_era.adapter +5 -0
- data/lib/hecks/adapters/driven/sqlite/projection.rb +5 -2
- data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/bluebook/assembly/marks.rb +13 -3
- data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
- 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/bluebook/meta_validator/judge.rb +25 -3
- data/lib/hecks/bluebook/model_check.rb +148 -17
- data/lib/hecks/forms/field_shape.rb +5 -3
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
- data/lib/hecks/fuzzing/era_boundary.rb +105 -0
- data/lib/hecks/fuzzing/form_census.rb +184 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
- data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
- data/lib/hecks/fuzzing/properties/guards.rb +103 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
- data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
- data/lib/hecks/fuzzing/properties.rb +23 -2
- data/lib/hecks/fuzzing/replay.rb +130 -20
- data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
- data/lib/hecks/fuzzing/self_consistency.rb +647 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
- data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
- data/lib/hecks/fuzzing/structural_skips.rb +146 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
- data/lib/hecks/fuzzing/value_generator.rb +55 -3
- data/lib/hecks/fuzzing.rb +6 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
- data/lib/hecks/naming.rb +70 -2
- data/lib/hecks/ports/persistence/append_only.rb +11 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +85 -5
- data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
- data/lib/hecks/projections/glossary/html.rb +250 -0
- data/lib/hecks/projections/glossary/markdown.rb +105 -0
- data/lib/hecks/projections/glossary/mermaid.rb +110 -0
- data/lib/hecks/projections/glossary/page.css +271 -0
- data/lib/hecks/projections/glossary/page.js +72 -0
- data/lib/hecks/projections/glossary/sections.rb +17 -0
- data/lib/hecks/projections/glossary/sentences.rb +205 -0
- data/lib/hecks/projections/glossary.rb +302 -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/projector/narrate_projector.rb +4 -11
- data/lib/hecks/query_specification/common/comparison.rb +27 -1
- 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/command_interpreter/mutation_applier.rb +26 -8
- data/lib/hecks/runtime/command_rules/references.rb +75 -0
- data/lib/hecks/runtime/entity_element.rb +168 -16
- data/lib/hecks/runtime/entity_interpreter.rb +68 -3
- data/lib/hecks/runtime/interpreting.rb +16 -7
- data/lib/hecks/runtime/query_interpreter.rb +66 -2
- data/lib/hecks/runtime/reaction_invocation.rb +70 -3
- data/lib/hecks/runtime/read_model_interpreter.rb +5 -4
- data/lib/hecks/runtime/refusal_wording.rb +5 -2
- data/lib/hecks/runtime/registry.rb +12 -0
- data/lib/hecks/runtime/routing.rb +67 -2
- data/lib/hecks/runtime/saga_interpreter.rb +38 -1
- data/lib/hecks/runtime/value/coercion.rb +77 -52
- data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
- data/lib/hecks/runtime/value.rb +7 -2
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- metadata +25 -2
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
require_relative "../projector"
|
|
2
|
+
require_relative "../naming"
|
|
3
|
+
require_relative "statements"
|
|
4
|
+
require_relative "glossary/sections"
|
|
5
|
+
require_relative "glossary/sentences"
|
|
6
|
+
require_relative "glossary/mermaid"
|
|
7
|
+
require_relative "glossary/markdown"
|
|
8
|
+
require_relative "glossary/html"
|
|
9
|
+
|
|
10
|
+
module Hecks
|
|
11
|
+
module Projections
|
|
12
|
+
# A CHAPTER, PROJECTED AS ITS UBIQUITOUS LANGUAGE — a glossary for the
|
|
13
|
+
# whole team, in the sense Evans meant (DDD ch. 2): ONE language,
|
|
14
|
+
# shared by domain experts and developers, written the same way in
|
|
15
|
+
# conversation, diagrams, documents and code, so that a subject-matter
|
|
16
|
+
# expert can read the model and say "yes, that's how it works" or
|
|
17
|
+
# "no, that's wrong". A banker, a support rep, the CEO and an engineer
|
|
18
|
+
# read the same page.
|
|
19
|
+
#
|
|
20
|
+
# THREE THINGS FOLLOW FROM THAT AUDIENCE. First, no type jargon: a
|
|
21
|
+
# term is a term, not "an Aggregate" or "a Value Object", and every
|
|
22
|
+
# identifier is spelled as a person says it (`Naming.words`: `ATMCard`
|
|
23
|
+
# is "ATM card"). Second, nothing invented: every sentence is either
|
|
24
|
+
# the domain author's own words (a `description`, a `goal`, an
|
|
25
|
+
# invariant) or built mechanically from a declared fact ("Recorded
|
|
26
|
+
# after Freeze account") — the same discipline `DocsProjector` holds
|
|
27
|
+
# to; a wrong sentence is worse than a missing one. Third, grouped
|
|
28
|
+
# under the aggregate each term belongs to, A to Z within it — a
|
|
29
|
+
# reader thinks "what does Account mean" before "what starts with A",
|
|
30
|
+
# and once there reads the way a dictionary reads.
|
|
31
|
+
#
|
|
32
|
+
# TWO FILES FROM ONE SOURCE. `glossary.md` is the document — every
|
|
33
|
+
# construct in it renders on GitHub as-is (headings, blockquotes,
|
|
34
|
+
# ```mermaid fences, lists, in-page links). `html/index.html` is that
|
|
35
|
+
# exact Markdown string rendered into a page with a navigation rail;
|
|
36
|
+
# it is built FROM the Markdown, not beside it, so the two cannot
|
|
37
|
+
# drift. `bin/project_glossary` writes both to `<domain>/glossary/` beside
|
|
38
|
+
# the bluebook (examples/banking, examples/pizzas, and the QA ledger in qa/).
|
|
39
|
+
#
|
|
40
|
+
# Projector.call(:glossary, bluebook: <the Bluebook chapter>)
|
|
41
|
+
# # => { "glossary.md" => "...", "html/index.html" => "..." }
|
|
42
|
+
module Glossary
|
|
43
|
+
extend Projector::Target
|
|
44
|
+
|
|
45
|
+
projects_as :glossary, emits: :files
|
|
46
|
+
|
|
47
|
+
# One term. `kind` is a Symbol the renderers never print; `facts`
|
|
48
|
+
# carries the declaration objects the sentence is built from;
|
|
49
|
+
# `headword` and `slug` are assigned once the document's order is
|
|
50
|
+
# known (a headword may need qualifying, a slug depends on what
|
|
51
|
+
# came before it).
|
|
52
|
+
Entry = Struct.new(:name, :kind, :within, :section, :facts, :headword, :slug, keyword_init: true)
|
|
53
|
+
|
|
54
|
+
# One `##` of the document: an aggregate (with its own object, for
|
|
55
|
+
# the lede and diagrams) or one of the three trailing groups.
|
|
56
|
+
Section = Struct.new(:name, :title, :aggregate, :terms, :slug, keyword_init: true)
|
|
57
|
+
|
|
58
|
+
Document = Struct.new(:bluebook, :sections, :index, keyword_init: true)
|
|
59
|
+
|
|
60
|
+
module_function
|
|
61
|
+
|
|
62
|
+
def call(bluebook:, options: {})
|
|
63
|
+
markdown = Markdown.render(document(bluebook))
|
|
64
|
+
{ "glossary.md" => markdown, "html/index.html" => Html.render(markdown) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# ── the document ─────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
def document(bluebook)
|
|
70
|
+
sections = sections(bluebook, entries(bluebook))
|
|
71
|
+
Slugs.assign!(bluebook, sections)
|
|
72
|
+
Document.new(bluebook: bluebook, sections: sections, index: Index.new(sections))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# AGGREGATES FIRST, A TO Z BY THEIR SPOKEN NAME ("Account" before
|
|
76
|
+
# "ATM card"), then the three groups nothing homes to one aggregate.
|
|
77
|
+
def sections(bluebook, entries)
|
|
78
|
+
grouped = entries.group_by(&:section)
|
|
79
|
+
list = bluebook.aggregates.sort_by { |aggregate| Naming.words(aggregate.hecks_name).downcase }.map do |aggregate|
|
|
80
|
+
Section.new(name: aggregate.hecks_name, title: Naming.words(aggregate.hecks_name), aggregate: aggregate,
|
|
81
|
+
terms: with_headwords(grouped.fetch(aggregate.hecks_name, []), aggregate.hecks_name))
|
|
82
|
+
end
|
|
83
|
+
[ROLES, READ_MODELS, REACTIONS].each do |name|
|
|
84
|
+
held = grouped[name == REACTIONS ? nil : name]
|
|
85
|
+
list << Section.new(name: name, title: name, terms: with_headwords(held, name)) if held
|
|
86
|
+
end
|
|
87
|
+
list
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A HEADWORD IS QUALIFIED ONLY WHEN IT WOULD REPEAT within its own
|
|
91
|
+
# section — "Open" the action and "Open (the list)" the question;
|
|
92
|
+
# "Return (key issuance)" beside another Return — never numbered
|
|
93
|
+
# (Chicago 18.9, MDN's disambiguation pages: qualify the headword).
|
|
94
|
+
def with_headwords(entries, section_name)
|
|
95
|
+
entries.each { |entry| entry.headword = Naming.words(entry.name) }
|
|
96
|
+
entries.group_by(&:headword).each_value do |group|
|
|
97
|
+
next if group.size == 1
|
|
98
|
+
|
|
99
|
+
group.each { |entry| entry.headword = qualified(entry, group, section_name) }
|
|
100
|
+
end
|
|
101
|
+
entries.sort_by { |entry| [entry.headword.downcase, entry.kind.to_s] }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def qualified(entry, group, section_name)
|
|
105
|
+
base = Naming.words(entry.name)
|
|
106
|
+
return "#{base} (the list)" if entry.kind == :query && group.any? { |other| other.kind == :command }
|
|
107
|
+
return "#{base} (#{Naming.words(entry.within).downcase})" if entry.within && entry.within != section_name
|
|
108
|
+
|
|
109
|
+
base
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# ── gathering ────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
# AN ENTITY CAN CARRY ITS OWN COMMANDS AND QUERIES TOO — walked the
|
|
115
|
+
# same one level down `DocsProjector` and `Projections::Diagrams`
|
|
116
|
+
# already walk it.
|
|
117
|
+
def holders(bluebook)
|
|
118
|
+
bluebook.aggregates.flat_map { |aggregate| [aggregate, *aggregate.entities] }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# EVERY HOLDER'S OWN AGGREGATE, ONE HOP OR ZERO — an aggregate maps
|
|
122
|
+
# to itself, an entity to whichever aggregate declared it. The one
|
|
123
|
+
# fact the grouping is built on.
|
|
124
|
+
def holder_aggregate(bluebook)
|
|
125
|
+
bluebook.aggregates.each_with_object({}) do |aggregate, map|
|
|
126
|
+
map[aggregate.hecks_name] = aggregate.hecks_name
|
|
127
|
+
aggregate.entities.each { |entity| map[entity.hecks_name] = aggregate.hecks_name }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# EVERY EVENT'S RAISERS — an event is never declared, only emitted,
|
|
132
|
+
# so its home is whichever aggregate the FIRST command that raises
|
|
133
|
+
# it belongs to; a policy or saga reacting to it inherits that home.
|
|
134
|
+
def event_raisers(bluebook)
|
|
135
|
+
holders(bluebook).each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |holder, map|
|
|
136
|
+
holder.commands.each { |command| command.emits.each { |event| map[event] << [holder, command] } }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def bare(qualified) = qualified.to_s.split(".").last
|
|
141
|
+
|
|
142
|
+
def entries(bluebook)
|
|
143
|
+
homes = holder_aggregate(bluebook)
|
|
144
|
+
raisers = event_raisers(bluebook)
|
|
145
|
+
home_of = ->(event) { raisers.key?(event) ? homes[raisers[event].first.first.hecks_name] : nil }
|
|
146
|
+
|
|
147
|
+
entries = []
|
|
148
|
+
entries += entity_entries(bluebook)
|
|
149
|
+
entries += value_object_entries(bluebook)
|
|
150
|
+
entries += verb_entries(bluebook, homes)
|
|
151
|
+
entries += event_entries(bluebook, raisers, home_of)
|
|
152
|
+
entries += policy_entries(bluebook, home_of)
|
|
153
|
+
entries += saga_entries(bluebook, home_of)
|
|
154
|
+
entries += role_entries(bluebook)
|
|
155
|
+
entries += read_model_entries(bluebook)
|
|
156
|
+
entries
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def entity_entries(bluebook)
|
|
160
|
+
bluebook.aggregates.flat_map do |aggregate|
|
|
161
|
+
aggregate.entities.map do |entity|
|
|
162
|
+
Entry.new(name: entity.hecks_name, kind: :entity, within: aggregate.hecks_name,
|
|
163
|
+
section: aggregate.hecks_name, facts: { entity: entity })
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# AGGREGATES ONLY — an entity declares no value objects of its own
|
|
169
|
+
# (`Bluebook::Entity` deliberately does not answer `value_objects`).
|
|
170
|
+
def value_object_entries(bluebook)
|
|
171
|
+
bluebook.aggregates.flat_map do |aggregate|
|
|
172
|
+
aggregate.value_objects.map do |value_object|
|
|
173
|
+
Entry.new(name: value_object.hecks_name, kind: :value_object, within: aggregate.hecks_name,
|
|
174
|
+
section: aggregate.hecks_name, facts: { value_object: value_object })
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def verb_entries(bluebook, homes)
|
|
180
|
+
holders(bluebook).flat_map do |holder|
|
|
181
|
+
commands = holder.commands.map do |command|
|
|
182
|
+
Entry.new(name: command.hecks_name, kind: :command, within: holder.hecks_name,
|
|
183
|
+
section: homes[holder.hecks_name], facts: { command: command, holder: holder })
|
|
184
|
+
end
|
|
185
|
+
queries = holder.queries.map do |query|
|
|
186
|
+
Entry.new(name: query.hecks_name, kind: :query, within: holder.hecks_name,
|
|
187
|
+
section: homes[holder.hecks_name], facts: { query: query })
|
|
188
|
+
end
|
|
189
|
+
commands + queries
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def event_entries(bluebook, raisers, home_of)
|
|
194
|
+
raisers.map do |event, raised_by|
|
|
195
|
+
reactions = bluebook.policies.select { |policy| bare(policy.on_event) == event }
|
|
196
|
+
Entry.new(name: event, kind: :event, section: home_of.call(event),
|
|
197
|
+
facts: { event: event, raised_by: raised_by, policies: reactions })
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def policy_entries(bluebook, home_of)
|
|
202
|
+
bluebook.policies.map do |policy|
|
|
203
|
+
Entry.new(name: policy.name, kind: :policy, section: home_of.call(bare(policy.on_event)),
|
|
204
|
+
facts: { policy: policy })
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def saga_entries(bluebook, home_of)
|
|
209
|
+
bluebook.process_managers.map do |saga|
|
|
210
|
+
shape = saga.to_h
|
|
211
|
+
Entry.new(name: shape[:name], kind: :saga, section: home_of.call(bare(shape[:starts_on])),
|
|
212
|
+
facts: { saga: shape })
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# CROSS-CUTTING BY NATURE — `System` and `Customer` issue commands
|
|
217
|
+
# across half the aggregates here — so a role belongs to no single
|
|
218
|
+
# one and gets its own section.
|
|
219
|
+
def role_entries(bluebook)
|
|
220
|
+
by_role = Hash.new { |hash, key| hash[key] = [] }
|
|
221
|
+
holders(bluebook).each do |holder|
|
|
222
|
+
holder.commands.select(&:role).each { |command| by_role[command.role] << [holder, command] }
|
|
223
|
+
end
|
|
224
|
+
by_role.map do |role, issues|
|
|
225
|
+
Entry.new(name: role, kind: :role, section: ROLES, facts: { role: role, commands: issues })
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# A read model joins heads from more than one aggregate — its own
|
|
230
|
+
# header says so — so it belongs to none of them.
|
|
231
|
+
def read_model_entries(bluebook)
|
|
232
|
+
bluebook.read_models.map do |read_model|
|
|
233
|
+
Entry.new(name: read_model.name, kind: :read_model, section: READ_MODELS, facts: { read_model: read_model })
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# ── anchors ──────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
# GITHUB'S OWN HEADING SLUGS, REPRODUCED — lowercase, punctuation
|
|
240
|
+
# dropped, spaces to hyphens, a repeat gets "-1", "-2" in document
|
|
241
|
+
# order. Computed here, once, in the order the headings will appear,
|
|
242
|
+
# so a link written into the Markdown lands on the same heading
|
|
243
|
+
# whether GitHub renders the `.md` or `Html` renders the page.
|
|
244
|
+
module Slugs
|
|
245
|
+
module_function
|
|
246
|
+
|
|
247
|
+
def github(text) = text.to_s.downcase.gsub(/[^\p{Word}\- ]/, "").tr(" ", "-")
|
|
248
|
+
|
|
249
|
+
def assign!(bluebook, sections)
|
|
250
|
+
seen = Hash.new(0)
|
|
251
|
+
take = lambda do |text|
|
|
252
|
+
base = github(text)
|
|
253
|
+
seen[base] += 1
|
|
254
|
+
seen[base] == 1 ? base : "#{base}-#{seen[base] - 1}"
|
|
255
|
+
end
|
|
256
|
+
take.call(Markdown.title(bluebook))
|
|
257
|
+
sections.each do |section|
|
|
258
|
+
section.slug = take.call(section.title)
|
|
259
|
+
section.terms.each { |entry| entry.slug = take.call(entry.headword) }
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# WHERE A DECLARED REFERENCE POINTS — keyed structurally (a command
|
|
265
|
+
# by its holder AND name, an event by its bare name), never by
|
|
266
|
+
# searching prose for a matching word. Anything not declared here
|
|
267
|
+
# (a cross-domain command like `Notifications.Send`) answers nil,
|
|
268
|
+
# and the sentence says it in words with no link.
|
|
269
|
+
class Index
|
|
270
|
+
def initialize(sections)
|
|
271
|
+
@by_key = {}
|
|
272
|
+
sections.each do |section|
|
|
273
|
+
@by_key[[:aggregate, section.name]] = section if section.aggregate
|
|
274
|
+
section.terms.each { |entry| @by_key[key_of(entry)] = entry }
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def key_of(entry)
|
|
279
|
+
case entry.kind
|
|
280
|
+
when :command, :query, :value_object then [entry.kind, entry.within, entry.name]
|
|
281
|
+
else [entry.kind, entry.name]
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def [](kind, name, within: nil)
|
|
286
|
+
@by_key[within ? [kind, within, name] : [kind, name]]
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# A Markdown link to a term's own heading, or the plain words when
|
|
290
|
+
# the chapter declares no such term. `label:` overrides the link
|
|
291
|
+
# text for a sentence that has to tell two same-named terms apart.
|
|
292
|
+
def link(kind, name, within: nil, label: nil)
|
|
293
|
+
target = self[kind, name, within: within]
|
|
294
|
+
return label || Naming.words(name) unless target
|
|
295
|
+
|
|
296
|
+
text = label || (target.is_a?(Section) ? target.title : target.headword)
|
|
297
|
+
"[#{text}](##{target.slug})"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
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`
|
|
@@ -284,18 +284,11 @@ module Hecks
|
|
|
284
284
|
|
|
285
285
|
# ── small sentence carpentry ──────────────────────────────────────
|
|
286
286
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
when 1 then items[0].to_s
|
|
291
|
-
when 2 then "#{items[0]} #{conj} #{items[1]}"
|
|
292
|
-
else "#{items[0..-2].join(', ')}, #{conj} #{items[-1]}"
|
|
293
|
-
end
|
|
294
|
-
end
|
|
287
|
+
# Both now live in `Naming` (a second projection, the glossary,
|
|
288
|
+
# needed them); kept here as names so this file reads as it did.
|
|
289
|
+
def to_sentence_list(items, conj: "and") = Naming.to_sentence_list(items, conj: conj)
|
|
295
290
|
|
|
296
|
-
def a_or_an(word)
|
|
297
|
-
%w[a e i o u].include?(word.to_s[0].to_s.downcase) ? "an" : "a"
|
|
298
|
-
end
|
|
291
|
+
def a_or_an(word) = Naming.a_or_an(word)
|
|
299
292
|
end
|
|
300
293
|
end
|
|
301
294
|
end
|
|
@@ -165,7 +165,33 @@ module Hecks
|
|
|
165
165
|
record = registry.repository(target_domain, target_ir).find(held)
|
|
166
166
|
return true unless record
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
# THE FIELD A STATE LIVES ON, READ FROM THE TARGET'S OWN
|
|
169
|
+
# DECLARATION — not assumed to be literally named `state`. Every
|
|
170
|
+
# `none_in_state` fixture this comparator originally shipped
|
|
171
|
+
# with (spec/query_none_in_state_*_spec.rb) happens to declare a
|
|
172
|
+
# plain `attribute :state, ...` rather than a real `lifecycle`,
|
|
173
|
+
# which is how the previous hardcoded `record.state[:state]`
|
|
174
|
+
# passed every one of them while being wrong for the shape this
|
|
175
|
+
# whole comparator exists to answer about: a real state MACHINE.
|
|
176
|
+
# `lifecycle :field, default: ... do ... end` stores its state
|
|
177
|
+
# under `field` (`Instance#assign_creation_attributes`'s own
|
|
178
|
+
# `state[aggregate.lifecycle.field.to_sym] = ...`), and this
|
|
179
|
+
# codebase's own convention overwhelmingly names that field
|
|
180
|
+
# `status`, not `state` (`QualityControl::Clearance`'s own
|
|
181
|
+
# `lifecycle :status` among many others) — so the hardcoded key
|
|
182
|
+
# silently read `nil` from every real lifecycle-backed target,
|
|
183
|
+
# comparable(nil) != state was true unconditionally, and
|
|
184
|
+
# `none_in_state` against ANY lifecycle aggregate answered
|
|
185
|
+
# "not excluded" for every row, always, no matter its actual
|
|
186
|
+
# state. Found chasing `QualityControl::Bug.AwaitingClearance`
|
|
187
|
+
# (qa/bluebook/quality_control.bluebook), which is exactly this
|
|
188
|
+
# shape: `Clearance:green`/`Clearance:red` against a `lifecycle
|
|
189
|
+
# :status` aggregate. Falls back to `:state` when the target
|
|
190
|
+
# declares no lifecycle at all, so every existing fixture (a
|
|
191
|
+
# plain attribute literally named `state`) keeps answering
|
|
192
|
+
# exactly as before.
|
|
193
|
+
field = target_ir.lifecycle&.field || :state
|
|
194
|
+
comparable(record.state[field]) != state
|
|
169
195
|
end
|
|
170
196
|
|
|
171
197
|
def find_aggregate_by_name(registry, name)
|
|
@@ -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
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require_relative "../value"
|
|
2
|
+
require_relative "../entity_element"
|
|
2
3
|
|
|
3
4
|
module Hecks
|
|
4
5
|
module Runtime
|
|
@@ -174,17 +175,24 @@ module Hecks
|
|
|
174
175
|
# Vendored addition, not (yet) upstream hecks (migration plan
|
|
175
176
|
# task 4): the removal counterpart to #appended -- matches by
|
|
176
177
|
# VALUE EQUALITY, element-wise, no read-modify-write (plan.
|
|
177
|
-
# bluebook's own words: "so a concurrent Add can never be lost")
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
#
|
|
178
|
+
# bluebook's own words: "so a concurrent Add can never be lost")
|
|
179
|
+
# -- UNLESS the target list is itself entity-typed (BUG#32,
|
|
180
|
+
# QualityControl ledger), in which case `EntityElement.
|
|
181
|
+
# list_element_match?` matches by the entity's own IDENTITY
|
|
182
|
+
# field instead -- see that method's own comment for the full
|
|
183
|
+
# "why identity, not whole-value equality" reasoning. `mutation.
|
|
184
|
+
# source` is a single field reference (`:dependency`), unlike
|
|
185
|
+
# append's field-map -- resolved and Value-coerced the SAME way
|
|
186
|
+
# increment/decrement already coerce their own amount, so the
|
|
187
|
+
# comparison is against a like-shaped Value, not a raw scalar
|
|
188
|
+
# against a wrapped one.
|
|
183
189
|
def removed(instance, aggregate, mutation, args)
|
|
184
190
|
value = @rules.resolve_source(mutation.source, args)
|
|
185
191
|
attribute = aggregate.attribute(mutation.target)
|
|
186
192
|
value = Value.for_attribute(aggregate, attribute, value) if attribute
|
|
187
|
-
Array(instance[mutation.target]).reject
|
|
193
|
+
Array(instance[mutation.target]).reject do |element|
|
|
194
|
+
EntityElement.list_element_match?(aggregate, attribute, element, value)
|
|
195
|
+
end
|
|
188
196
|
end
|
|
189
197
|
|
|
190
198
|
# Vendored fix, not (yet) upstream hecks (migration plan
|
|
@@ -246,7 +254,17 @@ module Hecks
|
|
|
246
254
|
check_entity_collision(aggregate, entity, current, fields)
|
|
247
255
|
end
|
|
248
256
|
fields[entity.lifecycle.field] ||= entity.lifecycle.default if entity.lifecycle
|
|
249
|
-
|
|
257
|
+
# BUG#12 — every one of THIS entity's own declared attributes
|
|
258
|
+
# the append mapping (and the identity/lifecycle filling just
|
|
259
|
+
# above) didn't already touch gets its own default, the same
|
|
260
|
+
# way a fresh aggregate's own attributes already do
|
|
261
|
+
# (`Instance.defaults`) — see `EntityElement#
|
|
262
|
+
# fill_declared_defaults`'s own comment for the full reasoning;
|
|
263
|
+
# shared rather than reimplemented so this aggregate-level
|
|
264
|
+
# entity creation and `EntityElement#appended_to_element`'s
|
|
265
|
+
# entity-nested-in-entity one can never drift on what "the
|
|
266
|
+
# default" means.
|
|
267
|
+
EntityElement.fill_declared_defaults(aggregate, entity, fields)
|
|
250
268
|
end
|
|
251
269
|
|
|
252
270
|
# THE MINTED IDENTITY IS ONE PAST THE HIGHEST HELD (C4.5) — not
|