hecks 0.3.0 → 1.0.1
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/d1.rb +38 -14
- data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
- data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
- data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
- data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
- data/lib/hecks/adapters/driven/heki.rb +40 -9
- data/lib/hecks/adapters/driven/lambda.rb +16 -2
- data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
- data/lib/hecks/adapters/driven/postgres.rb +76 -29
- data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
- data/lib/hecks/adapters/driven/sql_query_builder.rb +1 -1
- data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
- data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
- data/lib/hecks/adapters/driven/sqlite.rb +30 -9
- data/lib/hecks/adapters/driven.rb +19 -1
- data/lib/hecks/behaviors/dsl.rb +29 -0
- data/lib/hecks/behaviors/expectations.rb +62 -2
- data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
- data/lib/hecks/bluebook/assembly/marks.rb +4 -3
- data/lib/hecks/bluebook/assembly.rb +14 -1
- data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
- data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
- data/lib/hecks/bluebook/behaviour/value_object.rb +13 -7
- data/lib/hecks/bluebook/chapter.rb +21 -11
- data/lib/hecks/bluebook/command.rb +1 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
- data/lib/hecks/bluebook/dsl/command_builder.rb +144 -3
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +49 -7
- data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
- data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
- data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
- data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
- data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
- data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
- data/lib/hecks/bluebook/expression/ast_json.rb +149 -0
- data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
- data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
- data/lib/hecks/bluebook/expression/projection.json +48 -0
- data/lib/hecks/bluebook/expression/resolver.rb +161 -10
- data/lib/hecks/bluebook/expression.rb +1 -0
- data/lib/hecks/bluebook/hexagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
- data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
- data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
- data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
- data/lib/hecks/bluebook/meta_validator.rb +103 -13
- data/lib/hecks/bluebook/model_check.rb +147 -6
- data/lib/hecks/bluebook/pattern_subset.rb +66 -2
- data/lib/hecks/bluebook/process_manager.rb +53 -11
- data/lib/hecks/bluebook/project_register.rb +25 -0
- data/lib/hecks/bluebook/value_object.rb +19 -2
- data/lib/hecks/doc/reference.rb +22 -1
- data/lib/hecks/facade/cli_door.rb +6 -3
- data/lib/hecks/facade/json_door.rb +16 -4
- data/lib/hecks/forms/app.rb +47 -6
- data/lib/hecks/forms/command_form_renderer.rb +1 -1
- data/lib/hecks/forms/field_renderer.rb +11 -4
- data/lib/hecks/forms/html.rb +31 -0
- data/lib/hecks/forms/params.rb +30 -1
- data/lib/hecks/forms/port_argument.rb +46 -0
- data/lib/hecks/forms/record_renderer.rb +6 -2
- data/lib/hecks/forms/record_table.rb +6 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
- data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
- data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
- data/lib/hecks/fuzzing/invalid_value_generator.rb +3 -2
- data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
- data/lib/hecks/fuzzing/properties.rb +52 -6
- data/lib/hecks/fuzzing/replay.rb +51 -18
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
- data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
- data/lib/hecks/fuzzing/value_generator.rb +25 -2
- data/lib/hecks/grammar/evolve.rb +33 -0
- data/lib/hecks/grammar/expression.bluebook +8 -5
- data/lib/hecks/grammar/expression_operators.json +497 -1
- data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
- data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
- data/lib/hecks/language/bluebook/command.bluebook +42 -8
- data/lib/hecks/language/bluebook/entity.bluebook +86 -10
- data/lib/hecks/language/bluebook/policy.bluebook +21 -4
- data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
- data/lib/hecks/language/bluebook/projection.bluebook +6 -6
- data/lib/hecks/language/bluebook/query.bluebook +4 -4
- data/lib/hecks/language/bluebook/shape.bluebook +6 -6
- data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
- data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
- data/lib/hecks/language/hecksagon/domain_port.bluebook +25 -1
- data/lib/hecks/language/oidc.json +20 -0
- data/lib/hecks/language/port.bluebook +30 -2
- data/lib/hecks/naming.rb +54 -1
- data/lib/hecks/ports/access_control.port +7 -2
- data/lib/hecks/ports/access_control.rb +1 -1
- data/lib/hecks/ports/agent.port +6 -2
- data/lib/hecks/ports/agent.rb +1 -1
- data/lib/hecks/ports/authentication.port +4 -2
- data/lib/hecks/ports/authentication.rb +1 -1
- data/lib/hecks/ports/authorization.port +5 -2
- data/lib/hecks/ports/authorization.rb +14 -11
- data/lib/hecks/ports/clock.port +3 -2
- data/lib/hecks/ports/clock.rb +1 -1
- data/lib/hecks/ports/extraction.port +3 -2
- data/lib/hecks/ports/extraction.rb +1 -1
- data/lib/hecks/ports/identity_assignment.port +3 -2
- data/lib/hecks/ports/identity_assignment.rb +1 -1
- data/lib/hecks/ports/identity_generation.port +3 -2
- data/lib/hecks/ports/identity_generation.rb +1 -1
- data/lib/hecks/ports/identity_resolution.port +3 -2
- data/lib/hecks/ports/identity_resolution.rb +1 -1
- data/lib/hecks/ports/persistence/append_only.rb +40 -4
- data/lib/hecks/ports/persistence/execution.rb +6 -1
- data/lib/hecks/ports/persistence/plugin.rb +54 -0
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
- data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
- data/lib/hecks/ports/persistence.rb +1 -1
- data/lib/hecks/ports/projection.rb +20 -0
- data/lib/hecks/projections/diagrams.rb +230 -1
- data/lib/hecks/projections/shape.rb +15 -2
- data/lib/hecks/projector/cli_projector.rb +10 -1
- data/lib/hecks/projector/exporter.rb +19 -2
- data/lib/hecks/query_ir.rb +19 -0
- data/lib/hecks/query_specification/common/null_policy.rb +13 -1
- data/lib/hecks/query_specification/field_path.rb +20 -2
- data/lib/hecks/query_specification/hop_path.rb +7 -5
- data/lib/hecks/runtime/aggregate_lock.rb +45 -0
- data/lib/hecks/runtime/boot_gates.rb +41 -0
- data/lib/hecks/runtime/caller.rb +19 -3
- data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
- data/lib/hecks/runtime/command_interpreter.rb +165 -34
- data/lib/hecks/runtime/command_rules/admissibility.rb +101 -16
- data/lib/hecks/runtime/command_rules/arithmetic.rb +40 -4
- data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
- data/lib/hecks/runtime/command_rules/references.rb +27 -19
- data/lib/hecks/runtime/dependency_planning.rb +66 -5
- data/lib/hecks/runtime/dispatcher.rb +19 -4
- data/lib/hecks/runtime/entity_interpreter.rb +92 -14
- data/lib/hecks/runtime/errors.rb +22 -0
- data/lib/hecks/runtime/identity.rb +30 -2
- data/lib/hecks/runtime/instance.rb +59 -4
- data/lib/hecks/runtime/interpreting.rb +21 -0
- data/lib/hecks/runtime/loader.rb +59 -18
- data/lib/hecks/runtime/query_interpreter.rb +36 -4
- data/lib/hecks/runtime/reaction_invocation.rb +9 -1
- data/lib/hecks/runtime/read_model_interpreter.rb +89 -5
- data/lib/hecks/runtime/refusal_wording.rb +2 -0
- data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
- data/lib/hecks/runtime/registry/verification.rb +88 -0
- data/lib/hecks/runtime/registry.rb +69 -8
- data/lib/hecks/runtime/saga_interpreter.rb +215 -13
- data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
- data/lib/hecks/runtime/value/admission.rb +19 -1
- data/lib/hecks/runtime/value/coercion.rb +75 -10
- data/lib/hecks/runtime.rb +17 -5
- data/lib/hecks/storehouse.rb +632 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +6 -1
- data/lib/hecks.rb +28 -3
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
- metadata +49 -37
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
- /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
|
@@ -177,11 +177,26 @@ module Hecks
|
|
|
177
177
|
|
|
178
178
|
ordered = ordered_elements(rows, declared.order_by, declared.null_semantics,
|
|
179
179
|
parent_key, entity.identity_heads)
|
|
180
|
-
|
|
180
|
+
# OFFSET FIRST, THEN LIMIT — same fix, same reasoning, as
|
|
181
|
+
# #interpret's own rows above. `entity_rows` is the ONLY engine
|
|
182
|
+
# for entity/sub-list queries, so a declared offset here silently
|
|
183
|
+
# vanished for every entity query, not merely one path among
|
|
184
|
+
# several.
|
|
185
|
+
skipped = declared.offset ? ordered.drop(resolve_query_value(declared.offset.value, args).to_i) : ordered
|
|
186
|
+
declared.limit ? skipped.first(resolve_query_value(declared.limit.value, args).to_i) : skipped
|
|
181
187
|
end
|
|
182
188
|
|
|
189
|
+
# FieldPath.dig, not a raw `element[clause.field.to_sym]` — an
|
|
190
|
+
# entity sub-list row is a plain hash merged from stored state
|
|
191
|
+
# (mixed string/symbol keys depending on adapter, per `#cell`'s own
|
|
192
|
+
# comment below), and a dotted `where` (`where "price.cents" < 100`)
|
|
193
|
+
# needs the same segment-by-segment walk every other query path
|
|
194
|
+
# already gets. Reading only the symbol spelling of the WHOLE
|
|
195
|
+
# dotted string as one key always missed — `element[:"price.cents"]`
|
|
196
|
+
# is never a real key — so a dotted where on an entity query
|
|
197
|
+
# silently matched nothing, on the only engine entity queries have.
|
|
183
198
|
def element_where_holds?(clause, element, args)
|
|
184
|
-
holds?(clause, element
|
|
199
|
+
holds?(clause, QuerySpecification::FieldPath.dig(element, clause.field), args)
|
|
185
200
|
end
|
|
186
201
|
|
|
187
202
|
# A row's own key, however the store spells it. A sub-list row is a plain hash
|
|
@@ -191,7 +206,13 @@ module Hecks
|
|
|
191
206
|
# to remove. It rides `comparable` for the same reason a where-clause does : an
|
|
192
207
|
# identity is a value object, and `to_s` on one is an OBJECT ADDRESS — a sort key
|
|
193
208
|
# that differs run to run, which is worse than the store order it replaced.
|
|
194
|
-
|
|
209
|
+
# `key?` decides which spelling answers, never `||` — a row whose
|
|
210
|
+
# value is a genuinely-held `false` must not fall through to the
|
|
211
|
+
# other spelling (usually absent) and land on `nil`.
|
|
212
|
+
def cell(row, key)
|
|
213
|
+
sym = key.to_sym
|
|
214
|
+
row.key?(sym) ? row[sym] : row[key.to_s]
|
|
215
|
+
end
|
|
195
216
|
|
|
196
217
|
# A sub-list row is identified by its PARENT and then its own key : two
|
|
197
218
|
# entities under different parents can share a sequence, so the parent has
|
|
@@ -244,10 +265,21 @@ module Hecks
|
|
|
244
265
|
|
|
245
266
|
def comparable(value) = QuerySpecification::Common::Comparison.comparable(value)
|
|
246
267
|
|
|
268
|
+
# FieldPath.dig, not a raw `record[field]` — `record` is an Instance
|
|
269
|
+
# here, and a dotted order_by (`order_by "price.cents"`) is a
|
|
270
|
+
# single symbol key (`:"price.cents"`) that never matches anything
|
|
271
|
+
# `Instance#[]` actually holds, so a dotted order_by silently sorted
|
|
272
|
+
# by all-nil (the identity tier alone deciding every tie) on this,
|
|
273
|
+
# the reference/no-native-hook engine — the same bug already fixed
|
|
274
|
+
# for `where` (see `where_holds?` above) and for entity rows (see
|
|
275
|
+
# `ordered_elements` below), just not yet for this, the aggregate-
|
|
276
|
+
# level order_by.
|
|
247
277
|
def ordered(records, order_by, null_semantics = nil)
|
|
248
278
|
field = order_by&.field
|
|
249
279
|
Ports::Query::Ordering.apply(records, order_by, null_semantics,
|
|
250
|
-
identity: ->(record) { record.id.to_s }) { |record|
|
|
280
|
+
identity: ->(record) { record.id.to_s }) { |record|
|
|
281
|
+
comparable(QuerySpecification::FieldPath.dig(record, field))
|
|
282
|
+
}
|
|
251
283
|
end
|
|
252
284
|
end
|
|
253
285
|
end
|
|
@@ -52,8 +52,16 @@ module Hecks
|
|
|
52
52
|
visible = normalized_scopes.find { |scope| scope.facts.key?(source) }
|
|
53
53
|
unless visible
|
|
54
54
|
names = normalized_scopes.map(&:name).join(" then ")
|
|
55
|
+
# WHAT IS VISIBLE, NAMED. A refusal that only says which name is
|
|
56
|
+
# missing sent a modeler guessing field after field
|
|
57
|
+
# (`number`, `reference`…) at a fan-out row that is addressed
|
|
58
|
+
# by ONE key — `account`, the lowercase aggregate — which
|
|
59
|
+
# nothing else in the domain spells out. The names each scope
|
|
60
|
+
# actually offers are the whole diagnosis; the refusal now
|
|
61
|
+
# lists them, scope by scope.
|
|
62
|
+
offered = normalized_scopes.map { |scope| "#{scope.name}: #{scope.facts.keys.sort.join(', ')}" }.join("; ")
|
|
55
63
|
raise UnknownArgument,
|
|
56
|
-
"#{label}'s with: reads :#{source}, which is not visible in #{names}"
|
|
64
|
+
"#{label}'s with: reads :#{source}, which is not visible in #{names} (visible — #{offered})"
|
|
57
65
|
end
|
|
58
66
|
visible.facts.fetch(source)
|
|
59
67
|
end
|
|
@@ -68,15 +68,24 @@ module Hecks
|
|
|
68
68
|
root_heads, other_heads = model.aggregate_heads.partition { |head| head[:aggregate] == model.reference_target }
|
|
69
69
|
projected = []
|
|
70
70
|
rows_by_as = {}
|
|
71
|
-
(root_heads + other_heads).each do |head|
|
|
71
|
+
(root_heads + order_other_heads(bluebook, root_heads, other_heads)).each do |head|
|
|
72
72
|
rows = if head[:aggregate] == model.reference_target
|
|
73
73
|
[fetch(bluebook, domain, head[:aggregate], reference_id)]
|
|
74
74
|
elsif rootless
|
|
75
75
|
# No root to FK-match against — a rootless model reads
|
|
76
|
-
# each of its own heads WHOLE, independently.
|
|
77
|
-
# heads on one rootless model
|
|
78
|
-
# against each other
|
|
79
|
-
#
|
|
76
|
+
# each of its own heads WHOLE, independently. Multiple
|
|
77
|
+
# heads on one rootless model are NEVER cross-joined
|
|
78
|
+
# against each other, and there is no DSL to declare
|
|
79
|
+
# one if you wanted to — `ReadModelBuilder#include_impl`
|
|
80
|
+
# takes only `type`/`as:` (checked directly, not
|
|
81
|
+
# assumed), and `group_by` groups this bulk read's own
|
|
82
|
+
# output, it names no predicate between two heads.
|
|
83
|
+
# Building a cross-join here would mean CHOOSING a join
|
|
84
|
+
# semantics (equality on which fields?) nobody has
|
|
85
|
+
# declared — a real, deliberate scope limit pending a
|
|
86
|
+
# future `include ..., joins: ...`-shaped grammar
|
|
87
|
+
# addition (with its own Rust mirror), not a gap this
|
|
88
|
+
# interpreter can quietly grow into on its own.
|
|
80
89
|
records(bluebook, domain, head[:aggregate])
|
|
81
90
|
else
|
|
82
91
|
matching(records(bluebook, domain, head[:aggregate])) do |record|
|
|
@@ -112,6 +121,81 @@ module Hecks
|
|
|
112
121
|
end]
|
|
113
122
|
end
|
|
114
123
|
|
|
124
|
+
# THE ROOT-FIRST FIX'S OWN FIX — root-first alone only reaches one
|
|
125
|
+
# level: it guarantees the root is in `projected` before any other
|
|
126
|
+
# head is matched, but a CHAIN of non-root heads (a head that
|
|
127
|
+
# references another non-root head, not the root) is still
|
|
128
|
+
# matched against whatever declaration order happened to put in
|
|
129
|
+
# `projected` so far. `include Coupon` before `include Promotion`
|
|
130
|
+
# on a read model rooted at Item, where Coupon references
|
|
131
|
+
# Promotion (which references Item), silently returned an empty
|
|
132
|
+
# `coupons` array — Coupon's match ran while `projected` held only
|
|
133
|
+
# Item, one level short of what it needed.
|
|
134
|
+
#
|
|
135
|
+
# Fixed the same way root-first was: not by asking bluebook authors
|
|
136
|
+
# to declare `include` in dependency order (the same promise
|
|
137
|
+
# `read_model_builder.rb` already makes and this file is the one
|
|
138
|
+
# place obligated to keep), but by topologically sorting the
|
|
139
|
+
# non-root heads on their OWN declared reference fields before
|
|
140
|
+
# this method's runtime matching ever runs — Kahn's algorithm,
|
|
141
|
+
# picking ready heads in DECLARED order at each step so declaring
|
|
142
|
+
# order still governs whenever there is no dependency to break a
|
|
143
|
+
# tie. This generalizes root-first (a chain of length 1) to a
|
|
144
|
+
# chain of any depth, and to a head depending on more than one
|
|
145
|
+
# other head at once (not just a straight chain).
|
|
146
|
+
#
|
|
147
|
+
# A cycle among non-root heads (A references B which references A)
|
|
148
|
+
# has no valid topological order at all — falls back to the
|
|
149
|
+
# remaining heads' declared order rather than looping forever, the
|
|
150
|
+
# same "whichever runs first finds nothing" behaviour this method
|
|
151
|
+
# had for every non-root head before root-first existed.
|
|
152
|
+
def order_other_heads(bluebook, root_heads, other_heads)
|
|
153
|
+
resolved = root_heads.map { |head| head[:aggregate] }
|
|
154
|
+
remaining = other_heads.dup
|
|
155
|
+
ordered = []
|
|
156
|
+
until remaining.empty?
|
|
157
|
+
ready, blocked = remaining.partition do |head|
|
|
158
|
+
depends_on(bluebook, head, other_heads).all? { |target| resolved.include?(target) }
|
|
159
|
+
end
|
|
160
|
+
if ready.empty?
|
|
161
|
+
ordered.concat(remaining)
|
|
162
|
+
break
|
|
163
|
+
end
|
|
164
|
+
ordered.concat(ready)
|
|
165
|
+
resolved.concat(ready.map { |head| head[:aggregate] })
|
|
166
|
+
remaining = blocked
|
|
167
|
+
end
|
|
168
|
+
ordered
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Which OTHER declared (non-root) heads a head's own aggregate
|
|
172
|
+
# holds a reference field toward — the same relationship this
|
|
173
|
+
# file's runtime matching checks record-by-record, asked here
|
|
174
|
+
# statically, once, to order heads before any record is read.
|
|
175
|
+
#
|
|
176
|
+
# `head[:aggregate]` names whatever `include` was given — and
|
|
177
|
+
# `include` accepts a nested ENTITY (Member, nested under
|
|
178
|
+
# ValueObject ; Handler and Dispatch, nested under ProcessManager
|
|
179
|
+
# — bluebook.bluebook's own `WholeBluebook` read model includes
|
|
180
|
+
# all three) just as readily as a top-level aggregate.
|
|
181
|
+
# `bluebook.aggregate` only ever finds the latter
|
|
182
|
+
# (Behaviour::Chapter#aggregate searches `@aggregates`, which
|
|
183
|
+
# holds no entities), so it returns nil for an entity-headed
|
|
184
|
+
# include — a real case, not a malformed one. `records`, below,
|
|
185
|
+
# already treats that nil as "no rows of its own to fetch" ;
|
|
186
|
+
# a head with no rows of its own has nothing to check for a
|
|
187
|
+
# reference field either, so it depends on nothing here, the
|
|
188
|
+
# same as it always silently read empty before this file's
|
|
189
|
+
# topological sort existed.
|
|
190
|
+
def depends_on(bluebook, head, other_heads)
|
|
191
|
+
aggregate = bluebook.aggregate(head[:aggregate])
|
|
192
|
+
return [] unless aggregate
|
|
193
|
+
|
|
194
|
+
other_heads.reject { |other| other[:aggregate] == head[:aggregate] }
|
|
195
|
+
.select { |other| reference_fields(aggregate, other[:aggregate]).any? }
|
|
196
|
+
.map { |other| other[:aggregate] }
|
|
197
|
+
end
|
|
198
|
+
|
|
115
199
|
# `group_by`'s own declared fields, checked against the ONE
|
|
116
200
|
# many-side head they apply to (`seal_group_by` already refuses
|
|
117
201
|
# zero or several) — resolved here, once, rather than re-derived
|
|
@@ -70,6 +70,8 @@ module Hecks
|
|
|
70
70
|
"{type} is a composite identity — an identity must have exactly one field",
|
|
71
71
|
%w[TypeMismatch numeric_field] =>
|
|
72
72
|
"{type}.{field} expects {expected}, got {offered}",
|
|
73
|
+
%w[TypeMismatch non_finite_field] =>
|
|
74
|
+
"{type}.{field} must be a finite number, got {offered}",
|
|
73
75
|
%w[TypeMismatch pattern_mismatch] =>
|
|
74
76
|
"{type}.{field} must match {pattern}, got {offered}",
|
|
75
77
|
%w[TypeMismatch arithmetic_amount] =>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative "../saga_pending_dispatch"
|
|
2
|
+
|
|
1
3
|
module Hecks
|
|
2
4
|
module Runtime
|
|
3
5
|
class Registry
|
|
@@ -20,8 +22,27 @@ module Hecks
|
|
|
20
22
|
# shares one adapter), and is the honest, documented fallback
|
|
21
23
|
# for the rarer domain genuinely split across more than one
|
|
22
24
|
# local adapter with no default declared.
|
|
25
|
+
#
|
|
26
|
+
# GENUINELY LAZY, GENUINELY POST-BOOT — unlike `rehydrate_sagas!`
|
|
27
|
+
# below (boot-only), this is called from live dispatch
|
|
28
|
+
# (`SagaInterpreter#checkpoint`/`#end_saga`, on every saga
|
|
29
|
+
# transition), so the FIRST call for a given domain can come from
|
|
30
|
+
# any dispatching thread, not just the boot thread. `@saga_persistence`
|
|
31
|
+
# itself is a plain Hash stood up once in `Registry#initialize` (no
|
|
32
|
+
# race on the container), but `resolve_saga_persistence` is real work
|
|
33
|
+
# (a `BindingPolicy.resolve` plus a lazy `repository` build) whose
|
|
34
|
+
# RESULT — the actual adapter instance a domain's sagas persist
|
|
35
|
+
# through — must be the SAME object for every caller: two threads
|
|
36
|
+
# racing the first lookup and each building their own adapter would
|
|
37
|
+
# silently split one domain's saga writes across two adapter
|
|
38
|
+
# instances (worse than `Dispatcher#reaction_depth`'s M20 — that bug
|
|
39
|
+
# corrupted a counter; this one can corrupt WHICH STORE a saga's
|
|
40
|
+
# state lands in). Double-checked locking against a DEDICATED mutex
|
|
41
|
+
# — never `@saga_mutex` — see `Registry#initialize`'s own comment for
|
|
42
|
+
# why reusing that one would deadlock.
|
|
23
43
|
def saga_persistence(domain)
|
|
24
|
-
|
|
44
|
+
key = domain.to_s
|
|
45
|
+
@saga_persistence[key] || @saga_persistence_mutex.synchronize { @saga_persistence[key] ||= resolve_saga_persistence(key) }
|
|
25
46
|
end
|
|
26
47
|
|
|
27
48
|
# WALKS EVERY LOADED DOMAIN, repopulating `saga_instances` from
|
|
@@ -34,10 +55,23 @@ module Hecks
|
|
|
34
55
|
# boot (`Loader.boot`, between `verify!` and dispatcher
|
|
35
56
|
# construction) — a process that's been running has no reason to
|
|
36
57
|
# re-walk its own already-current `saga_instances`.
|
|
58
|
+
#
|
|
59
|
+
# BOOT-TIME-ONLY (OR ITS TEST-RUNNER EQUIVALENT) — this method's
|
|
60
|
+
# only callers are `Loader.run_boot_gates!` (single-threaded, before
|
|
61
|
+
# `dispatcher_for` ever exists) and `Registry#reset_runtime_state!`
|
|
62
|
+
# (single-threaded test runner — see that method's own comment).
|
|
63
|
+
# Never called from live dispatch, so `@saga_instances` mutation
|
|
64
|
+
# here has no concurrent caller to race, unlike its OTHER two write
|
|
65
|
+
# points inside `@saga_mutex.synchronize` blocks (`saga_interpreter.
|
|
66
|
+
# rb`), which genuinely do and are guarded accordingly.
|
|
67
|
+
# rubocop:disable-next Hecks/ThreadSharedIvarMutation
|
|
37
68
|
def rehydrate_sagas!
|
|
38
69
|
@hecksagons.each_key do |domain|
|
|
39
|
-
saga_persistence(domain).each_saga do |process_manager, correlation, state, memory|
|
|
40
|
-
|
|
70
|
+
saga_persistence(domain).each_saga do |process_manager, correlation, state, memory, completed_compensations = []|
|
|
71
|
+
pending = memory.delete(SAGA_PENDING_DISPATCH_KEY)
|
|
72
|
+
@saga_instances[process_manager][correlation] =
|
|
73
|
+
{ state: state, memory: memory, completed_compensations: completed_compensations || [] }
|
|
74
|
+
warn_stalled_saga(domain, process_manager, correlation, state, pending) if pending
|
|
41
75
|
end
|
|
42
76
|
end
|
|
43
77
|
self
|
|
@@ -45,6 +79,44 @@ module Hecks
|
|
|
45
79
|
|
|
46
80
|
private
|
|
47
81
|
|
|
82
|
+
# THE OTHER HALF OF THE OUTBOX-SHAPED FIX (see saga_pending_
|
|
83
|
+
# dispatch.rb) — `SAGA_PENDING_DISPATCH_KEY`, if the crashed
|
|
84
|
+
# process left it standing, means the row's own `state` was
|
|
85
|
+
# checkpointed but the dispatch cascade that justifies it may
|
|
86
|
+
# never have run. Stripped out of `memory` before it becomes
|
|
87
|
+
# this instance's LIVE `:memory` (so nothing downstream — a
|
|
88
|
+
# `given`, a `with:` mapping, the fuzzer — ever sees it), and
|
|
89
|
+
# surfaced loudly instead: a WARNING plus a `saga_log` entry,
|
|
90
|
+
# never an automatic redrive (see saga_pending_dispatch.rb for
|
|
91
|
+
# why redriving without idempotent delivery would be worse than
|
|
92
|
+
# the stall). This is real, durable crash-recovery VISIBILITY —
|
|
93
|
+
# still not the reconciliation itself, which stays a human's
|
|
94
|
+
# call until hecks has idempotent redelivery to make it safe.
|
|
95
|
+
# rubocop:disable-next Hecks/ThreadSharedIvarMutation -- same
|
|
96
|
+
# justification as `rehydrate_sagas!`'s own disable comment
|
|
97
|
+
# above: this method's ONLY caller is that boot-time-only walk,
|
|
98
|
+
# never live dispatch, so `@saga_log` has no concurrent writer
|
|
99
|
+
# to race here.
|
|
100
|
+
def warn_stalled_saga(domain, process_manager, correlation, state, pending)
|
|
101
|
+
# `.transform_keys(&:to_sym)` — Heki's own `each_saga` only
|
|
102
|
+
# symbolizes `memory`'s TOP-level keys (`SagaStore#each_saga`'s
|
|
103
|
+
# own `transform_keys`, one level deep); a value NESTED under
|
|
104
|
+
# one of those keys, like this marker, comes back with plain
|
|
105
|
+
# string keys from Heki specifically, symbol keys already from
|
|
106
|
+
# Postgres/SQLite/D1's own `symbolize_names: true` parse. Normalizing
|
|
107
|
+
# here, once, is simpler than teaching every adapter's own
|
|
108
|
+
# shallow/deep parsing convention about this one reserved key.
|
|
109
|
+
pending = pending.transform_keys(&:to_sym)
|
|
110
|
+
dispatches = Array(pending[:dispatches]).join(", ")
|
|
111
|
+
warn "[hecks] #{domain} rehydrated #{process_manager} instance #{correlation.inspect} in state " \
|
|
112
|
+
"#{state.inspect} with a dispatch left pending from before the last crash/restart — " \
|
|
113
|
+
"#{dispatches} (on #{pending[:on].inspect}, #{pending[:from].inspect} -> #{pending[:to].inspect}) " \
|
|
114
|
+
"may or may not have actually run. hecks does not auto-redrive a pending saga dispatch (that " \
|
|
115
|
+
"needs idempotent delivery, which this pipeline doesn't have yet); reconcile this instance by hand."
|
|
116
|
+
@saga_log << { process_manager: process_manager, instance: correlation, rehydrated_stalled: true,
|
|
117
|
+
state: state, pending: pending }
|
|
118
|
+
end
|
|
119
|
+
|
|
48
120
|
def resolve_saga_persistence(domain)
|
|
49
121
|
anchor = hecksagon(domain) && bluebook(domain)&.aggregates&.first
|
|
50
122
|
return Ports::Persistence::NULL_SAGA_STORE unless anchor
|
|
@@ -12,6 +12,7 @@ module Hecks
|
|
|
12
12
|
module Verification
|
|
13
13
|
def verify!
|
|
14
14
|
verify_default_adapter!
|
|
15
|
+
verify_singleton_port_answers!
|
|
15
16
|
|
|
16
17
|
@hecksagons.each_value do |hexagon|
|
|
17
18
|
refuse_ungoverned_roles!(hexagon)
|
|
@@ -42,6 +43,8 @@ module Hecks
|
|
|
42
43
|
|
|
43
44
|
repository(hexagon.domain, aggregate)
|
|
44
45
|
end
|
|
46
|
+
|
|
47
|
+
warn_undurable_sagas!(hexagon)
|
|
45
48
|
end
|
|
46
49
|
self
|
|
47
50
|
end
|
|
@@ -66,6 +69,7 @@ module Hecks
|
|
|
66
69
|
|
|
67
70
|
def check_verb(bind)
|
|
68
71
|
port = port_for(bind)
|
|
72
|
+
check_answers(port, bind.adapter)
|
|
69
73
|
return if port.verb.to_s == bind.verb.to_s
|
|
70
74
|
|
|
71
75
|
raise WiringError,
|
|
@@ -73,6 +77,55 @@ module Hecks
|
|
|
73
77
|
"and cannot satisfy #{bind.verb}"
|
|
74
78
|
end
|
|
75
79
|
|
|
80
|
+
# THE METHOD CONTRACT A `.port` FILE'S `verb`/`signal` NEVER
|
|
81
|
+
# CARRIED — an adapter can name the right port, satisfy the right
|
|
82
|
+
# verb, and admit every `.world` setting `check_settings` checks,
|
|
83
|
+
# and still be missing the one method a live dispatch will
|
|
84
|
+
# actually call. `answers` is optional per port (an empty list is
|
|
85
|
+
# today's pre-existing behavior, unchecked), so this only ever
|
|
86
|
+
# tightens a port that opted in.
|
|
87
|
+
def check_answers(port, adapter_name)
|
|
88
|
+
answers = Array(port.answers)
|
|
89
|
+
return if answers.empty?
|
|
90
|
+
|
|
91
|
+
klass = adapter_class(adapter_name)
|
|
92
|
+
missing = answers.reject { |method_name| klass.respond_to?(method_name) }
|
|
93
|
+
return if missing.empty?
|
|
94
|
+
|
|
95
|
+
raise WiringError,
|
|
96
|
+
"#{adapter_name} declares the #{port.name} port but does not respond to " \
|
|
97
|
+
"#{missing.map(&:inspect).join(', ')} — #{port.name}.port declares answers " \
|
|
98
|
+
"#{answers.map(&:inspect).join(', ')}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# THE NINE SINGLETON PORTS' OWN GAP — `persistence`, `projection`
|
|
102
|
+
# and `loading` are per-aggregate bindings, checked above through
|
|
103
|
+
# every real `bind` a hexagon declares; a singleton port
|
|
104
|
+
# (`clock`, `authorization`, …) is never bound to an aggregate at
|
|
105
|
+
# all, so nothing above ever resolves one and nothing above ever
|
|
106
|
+
# ran `check_answers` against it. Each one's own `Ports::*.adapter`
|
|
107
|
+
# already refuses zero or multiple implementations, live, at
|
|
108
|
+
# first dispatch — that stays exactly as-is here (0 or 2+ is
|
|
109
|
+
# ambiguity, not a method-contract question, and asserting every
|
|
110
|
+
# declared port MUST have exactly one adapter would wrongly
|
|
111
|
+
# refuse a boot that simply never wires a port it doesn't use).
|
|
112
|
+
# This only ever tightens the ONE case those checks don't cover:
|
|
113
|
+
# exactly one adapter, wired, missing a method `answers` names.
|
|
114
|
+
PER_AGGREGATE_PORTS = %w[persistence projection loading].freeze
|
|
115
|
+
|
|
116
|
+
def verify_singleton_port_answers!
|
|
117
|
+
@ports.each_value do |port|
|
|
118
|
+
next if PER_AGGREGATE_PORTS.include?(port.name)
|
|
119
|
+
next if Array(port.answers).empty?
|
|
120
|
+
|
|
121
|
+
implementations = @adapters.values.select { |a| a.port == port.name }
|
|
122
|
+
next unless implementations.size == 1
|
|
123
|
+
|
|
124
|
+
check_answers(port, implementations.first.name)
|
|
125
|
+
end
|
|
126
|
+
self
|
|
127
|
+
end
|
|
128
|
+
|
|
76
129
|
def check_settings(bind, settings)
|
|
77
130
|
adapter = @adapters[bind.adapter]
|
|
78
131
|
return unless adapter
|
|
@@ -152,6 +205,41 @@ module Hecks
|
|
|
152
205
|
def commands_in(bluebook_ir)
|
|
153
206
|
bluebook_ir.aggregates.flat_map { |aggregate| aggregate.commands + aggregate.entities.flat_map(&:commands) }
|
|
154
207
|
end
|
|
208
|
+
|
|
209
|
+
# A domain that declares a `process_manager` but whose
|
|
210
|
+
# `saga_persistence` resolves to `NULL_SAGA_STORE` (no anchor
|
|
211
|
+
# aggregate, a RemoteRuntime-shaped adapter, an adapter that
|
|
212
|
+
# doesn't `respond_to?(:save_saga)`, or a rescued WiringError —
|
|
213
|
+
# see `SagaPersistence#resolve_saga_persistence`) gets sagas that
|
|
214
|
+
# advance correctly in-process and vanish on restart: no
|
|
215
|
+
# checkpoint written, nothing for `rehydrate_sagas!` to find, no
|
|
216
|
+
# compensation ever replayed. That is silent right up until the
|
|
217
|
+
# process actually dies mid-saga — the same "consistency/
|
|
218
|
+
# freshness defect applied to access control, failing open" ADR
|
|
219
|
+
# 0025 named for an unchecked `role`, here applied to saga
|
|
220
|
+
# durability instead.
|
|
221
|
+
#
|
|
222
|
+
# A WARNING, NOT A REFUSAL — unlike `refuse_ungoverned_roles!`,
|
|
223
|
+
# running sagas on a store with no `save_saga` is legitimate on
|
|
224
|
+
# purpose in a fast in-memory test/dev boot (this project's own
|
|
225
|
+
# `saga_durability_spec.rb` boots a process manager on `Memory`
|
|
226
|
+
# specifically to exercise the saga_mutex without real I/O), so
|
|
227
|
+
# refusing the boot outright would break a choice an author made
|
|
228
|
+
# deliberately. What a deploy needs is for the gap to be loud and
|
|
229
|
+
# undeniable, not for local dev/test to become impossible.
|
|
230
|
+
def warn_undurable_sagas!(hexagon)
|
|
231
|
+
bluebook_ir = bluebook(hexagon.domain)
|
|
232
|
+
return unless bluebook_ir
|
|
233
|
+
return if bluebook_ir.process_managers.empty?
|
|
234
|
+
return unless saga_persistence(hexagon.domain).equal?(Ports::Persistence::NULL_SAGA_STORE)
|
|
235
|
+
|
|
236
|
+
names = bluebook_ir.process_managers.map(&:name).join(", ")
|
|
237
|
+
warn "[hecks] #{hexagon.domain} declares process_manager(s) #{names} but its resolved " \
|
|
238
|
+
"persistence adapter has no save_saga — saga state advances correctly in-process " \
|
|
239
|
+
"and is LOST on restart (no checkpoint, no rehydration, no compensation replay). " \
|
|
240
|
+
"Bind this domain to an adapter that implements save_saga if this process_manager " \
|
|
241
|
+
"must survive a crash."
|
|
242
|
+
end
|
|
155
243
|
end
|
|
156
244
|
end
|
|
157
245
|
end
|
|
@@ -45,9 +45,11 @@ module Hecks
|
|
|
45
45
|
# GUARDS `saga_instances`' OWN mutation+checkpoint sequence
|
|
46
46
|
# (`SagaInterpreter`'s 4 write points, §7) — the same shape of
|
|
47
47
|
# hazard this codebase's own prior audit already flagged for
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
48
|
+
# `Dispatcher#reenter`'s reaction-depth counter (M20: a
|
|
49
|
+
# thread-shared ivar with no lock, since fixed by moving it to
|
|
50
|
+
# `Thread.current`, dispatcher.rb), made meaningfully easier to
|
|
51
|
+
# hit here once a persistence write sits in the same critical
|
|
52
|
+
# section. Held across the in-memory
|
|
51
53
|
# mutation AND the checkpoint write together, never across a
|
|
52
54
|
# saga's own dispatch cascade — see `SagaInterpreter#advance_saga`'s
|
|
53
55
|
# own comment for why that distinction matters (non-reentrant
|
|
@@ -56,6 +58,35 @@ module Hecks
|
|
|
56
58
|
@repositories = {}
|
|
57
59
|
@projection_repositories = {}
|
|
58
60
|
@bluebook_builders = {}
|
|
61
|
+
# EAGER, NOT LAZY — see `#resolved_eras`'s own comment for why. Built
|
|
62
|
+
# here rather than `@resolved_eras ||= {}` on first access so there is
|
|
63
|
+
# no window, post-boot, where two concurrently dispatching threads
|
|
64
|
+
# could race creating this Hash (Hecks/ThreadSharedIvarMutation; the
|
|
65
|
+
# same shape of hazard `Dispatcher#reenter`'s `@reaction_depth` was
|
|
66
|
+
# fixed for). Every WRITE into it still only ever happens at boot,
|
|
67
|
+
# single-threaded (`EraResolver.check!`, a `:pre_verify` boot gate) —
|
|
68
|
+
# this only removes the race on standing up the container itself for
|
|
69
|
+
# a boot with no era-plugin domain at all, whose first touch would
|
|
70
|
+
# otherwise be a live dispatch's own `RepositoryFactory.build` read.
|
|
71
|
+
@resolved_eras = {}
|
|
72
|
+
# EAGER, NOT LAZY — see `#capability_graph`'s own comment for why.
|
|
73
|
+
# `CapabilityGraph.new` only stores the registry reference; there is
|
|
74
|
+
# no reason to defer it, and doing so removes the exact same
|
|
75
|
+
# first-access race `#resolved_eras` above does, while preserving the
|
|
76
|
+
# "same instance every call" identity `spec/runtime/capability_graph_
|
|
77
|
+
# spec.rb` already requires.
|
|
78
|
+
@capability_graph = CapabilityGraph.new(self)
|
|
79
|
+
# `@saga_persistence` itself is eager (see `#saga_persistence`'s own
|
|
80
|
+
# comment) — only the PER-DOMAIN resolution inside it is genuinely
|
|
81
|
+
# expensive and lazy, guarded by this dedicated mutex. NOT the same
|
|
82
|
+
# mutex as `@saga_mutex`: `checkpoint` (saga_interpreter.rb) calls
|
|
83
|
+
# `saga_persistence(domain)` from INSIDE an `@saga_mutex.synchronize`
|
|
84
|
+
# block, so reusing `@saga_mutex` here would deadlock the very first
|
|
85
|
+
# time a saga advanced (a `Mutex` is not reentrant — the exact
|
|
86
|
+
# warning `@saga_mutex`'s own comment already gives for a different
|
|
87
|
+
# reason).
|
|
88
|
+
@saga_persistence = {}
|
|
89
|
+
@saga_persistence_mutex = Mutex.new
|
|
59
90
|
end
|
|
60
91
|
|
|
61
92
|
# THE BUILDER STAYS OPEN FOR THE LIFE OF THIS REGISTRY, keyed by chapter
|
|
@@ -67,6 +98,20 @@ module Hecks
|
|
|
67
98
|
@bluebook_builders[name.to_s] ||= yield
|
|
68
99
|
end
|
|
69
100
|
|
|
101
|
+
# BOOT-TIME-ONLY, SINGLE-THREADED — every `add_*` below (through
|
|
102
|
+
# `add_translation`) is called exclusively from `Hecks.collect`
|
|
103
|
+
# (hecks.rb), which is what `Hecks.bluebook`/`.hecksagon`/`.port`/
|
|
104
|
+
# `.adapter`/`.world`/`.translation` run inside while a `.bluebook`/
|
|
105
|
+
# `.hecksagon`/`.world` file is being `Kernel.load`ed — i.e. strictly
|
|
106
|
+
# during `Loader.boot`/`.boot_files`, before `dispatcher_for` ever
|
|
107
|
+
# hands this registry to a live, multi-threaded caller. Nothing
|
|
108
|
+
# downstream of boot ever calls these — verified by grepping every
|
|
109
|
+
# call site in lib/ and spec/ before writing this — so unlike
|
|
110
|
+
# `#resolved_eras`/`#capability_graph`/`#saga_persistence` (each
|
|
111
|
+
# reachable from live dispatch, and fixed for real above/in
|
|
112
|
+
# registry/saga_persistence.rb) there is no concurrent caller for
|
|
113
|
+
# `Hecks/ThreadSharedIvarMutation` to actually be warning about here.
|
|
114
|
+
# rubocop:disable Hecks/ThreadSharedIvarMutation
|
|
70
115
|
def add_bluebook(item) = @bluebooks[item.name] = item
|
|
71
116
|
|
|
72
117
|
# MERGED, NOT REPLACED — RECOVERED, not new (see Runtime::Loader
|
|
@@ -99,12 +144,16 @@ module Hecks
|
|
|
99
144
|
end
|
|
100
145
|
|
|
101
146
|
def add_translation(item) = @translations << item
|
|
147
|
+
# rubocop:enable Hecks/ThreadSharedIvarMutation
|
|
102
148
|
|
|
103
149
|
# {domain name => era ordinal} as resolved by the boot-time era
|
|
104
150
|
# gate. A lineage adapter writes into ITS OWN era's partition —
|
|
105
151
|
# which, for an old checkout booting a held-but-superseded shape,
|
|
106
|
-
# is not the newest one.
|
|
107
|
-
|
|
152
|
+
# is not the newest one. The Hash itself is stood up in `initialize`
|
|
153
|
+
# (see that comment) — this is a plain reader, not a memoizer;
|
|
154
|
+
# `Hecks/ThreadSharedIvarMutation` is the reason there is no `||=`
|
|
155
|
+
# left here to flag.
|
|
156
|
+
attr_reader :resolved_eras
|
|
108
157
|
|
|
109
158
|
def bluebook(name) = @bluebooks[name.to_s]
|
|
110
159
|
def hecksagon(name) = @hecksagons[name.to_s]
|
|
@@ -133,6 +182,16 @@ module Hecks
|
|
|
133
182
|
# test to get isolation (`Behaviors::Expectations.run_one`) — ~2s a
|
|
134
183
|
# boot, 76 chess behaviours = two and a half minutes of booting the
|
|
135
184
|
# same two files — can now boot once and reset between tests.
|
|
185
|
+
#
|
|
186
|
+
# SINGLE-THREADED CALLER, THE SAME REASON THE `add_*` CLUSTER ABOVE
|
|
187
|
+
# IS EXEMPT — `Behaviors::Expectations.run_one` is this method's ONLY
|
|
188
|
+
# caller (verified by grep before writing this), and it runs one
|
|
189
|
+
# test at a time: `Runner#run` maps over tests sequentially, and
|
|
190
|
+
# `Behaviors.rspec`'s generated examples run under RSpec's own
|
|
191
|
+
# single-threaded example loop. No production dispatch path calls
|
|
192
|
+
# this at all — a live Puma worker pool never resets a registry out
|
|
193
|
+
# from under itself mid-flight.
|
|
194
|
+
# rubocop:disable-next Hecks/ThreadSharedIvarMutation
|
|
136
195
|
def reset_runtime_state!
|
|
137
196
|
@event_log.clear
|
|
138
197
|
@reaction_log.clear
|
|
@@ -146,9 +205,11 @@ module Hecks
|
|
|
146
205
|
self
|
|
147
206
|
end
|
|
148
207
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
208
|
+
# Built eagerly in `initialize` (see that comment) — this is a plain
|
|
209
|
+
# reader, not a memoizer; `spec/runtime/capability_graph_spec.rb`
|
|
210
|
+
# asserts the SAME instance comes back every call, which this still
|
|
211
|
+
# gives, just without a lazy `||=` race on standing it up.
|
|
212
|
+
attr_reader :capability_graph
|
|
152
213
|
|
|
153
214
|
def read_repository(domain, aggregate)
|
|
154
215
|
key = [domain.to_s, aggregate.hecks_name]
|