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
|
@@ -62,13 +62,51 @@ module Hecks
|
|
|
62
62
|
class Analyzer
|
|
63
63
|
STATEFUL_MUTATIONS = %i[append increment decrement multiply clamp remove].freeze
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
# `root_aggregate:` — Wave 8's own audit surfaced a real bug here,
|
|
66
|
+
# not merely a missing feature: for an ENTITY-owned command,
|
|
67
|
+
# `EntityInterpreter` calls this with `aggregate:` set to the
|
|
68
|
+
# ENTITY itself (`element_interpreter.rb`'s own `Analyzer.call
|
|
69
|
+
# (aggregate: entity, command:)`), so `owner_fields` was always
|
|
70
|
+
# the entity's own attribute set. A `given`/`ensures` reading
|
|
71
|
+
# `parent.X` legitimately means the ROOT aggregate's own field —
|
|
72
|
+
# a genuinely different owner — but `classify_path`'s `:parent`
|
|
73
|
+
# branch checked that read against `owner_fields` (the entity's),
|
|
74
|
+
# which can never contain a root-level field, so every entity
|
|
75
|
+
# command with a real, legitimate `parent.*` read was refused as
|
|
76
|
+
# unresolved regardless of correctness. Defaults to `aggregate`
|
|
77
|
+
# (a no-op) for the plain-aggregate case — `CommandInterpreter`'s
|
|
78
|
+
# own call site never needed to change.
|
|
79
|
+
def self.call(aggregate:, command:, root_aggregate: aggregate) = new(aggregate, command, root_aggregate).call
|
|
80
|
+
|
|
81
|
+
def initialize(aggregate, command, root_aggregate = aggregate)
|
|
68
82
|
@aggregate = aggregate
|
|
69
83
|
@command = command
|
|
70
84
|
@owner_fields = aggregate.attributes.to_set(&:name)
|
|
71
85
|
@owner_fields << aggregate.lifecycle.field.to_sym if aggregate.lifecycle
|
|
86
|
+
@root_owner_fields = root_aggregate.attributes.to_set(&:name)
|
|
87
|
+
@root_owner_fields << root_aggregate.lifecycle.field.to_sym if root_aggregate.lifecycle
|
|
88
|
+
# `projects` FIELDS (S12, ADR 0025) ARE OWNER STATE TOO — a
|
|
89
|
+
# `given`/`ensures` reading one (e.g. `customer_status ==
|
|
90
|
+
# "active"`) is reading this record's own stored field, same
|
|
91
|
+
# as any attribute, even though nothing here WRITES it via a
|
|
92
|
+
# declared mutation (`CommandInterpreter#seed_projected_fields`
|
|
93
|
+
# populates it outside this analysis entirely). Left out of
|
|
94
|
+
# `known_writes` deliberately: `add_preservation_reads` then
|
|
95
|
+
# correctly treats it as a prior-state read that must survive
|
|
96
|
+
# a partial mutation, which is exactly right — a projected
|
|
97
|
+
# field's freshness comes from the interpreter reseeding it on
|
|
98
|
+
# save, not from anything a caller-supplied write set carries.
|
|
99
|
+
# Applies to BOTH `owner_fields` and `root_owner_fields` — an
|
|
100
|
+
# entity's own `parent.*` read can name the root aggregate's
|
|
101
|
+
# projected field just as easily as one of its real attributes
|
|
102
|
+
# (`Banking::Withdrawal.Dispute`'s own `parent.account_customer_
|
|
103
|
+
# status`, ATMCard's projected field, is a real, live example).
|
|
104
|
+
if aggregate.respond_to?(:projected_fields)
|
|
105
|
+
aggregate.projected_fields.each { |field| @owner_fields << field.name }
|
|
106
|
+
end
|
|
107
|
+
if root_aggregate.respond_to?(:projected_fields)
|
|
108
|
+
root_aggregate.projected_fields.each { |field| @root_owner_fields << field.name }
|
|
109
|
+
end
|
|
72
110
|
@payload_fields = command.attributes.to_set(&:name)
|
|
73
111
|
@state_reads = Set.new
|
|
74
112
|
@payload_reads = Set.new
|
|
@@ -101,7 +139,7 @@ module Hecks
|
|
|
101
139
|
|
|
102
140
|
private
|
|
103
141
|
|
|
104
|
-
attr_reader :aggregate, :command, :owner_fields, :payload_fields,
|
|
142
|
+
attr_reader :aggregate, :command, :owner_fields, :root_owner_fields, :payload_fields,
|
|
105
143
|
:state_reads, :payload_reads, :writes, :known_writes, :unresolved
|
|
106
144
|
|
|
107
145
|
# A fresh Instance supplies these values without reading a stored
|
|
@@ -190,13 +228,36 @@ module Hecks
|
|
|
190
228
|
end
|
|
191
229
|
end
|
|
192
230
|
|
|
231
|
+
# KNOWN, HARMLESS GAP: `corrects ..., as: :name`'s bound name
|
|
232
|
+
# (admissibility.rb's `enforce_correction_target`/`enforce_givens`/
|
|
233
|
+
# `enforce_ensures`) isn't special-cased here the way `:old`/
|
|
234
|
+
# `:parent` are — a given/ensures referencing it falls through to
|
|
235
|
+
# `unresolved` below (its own field lookup finds no owner/payload
|
|
236
|
+
# match), same net effect as any other not-yet-optimized command:
|
|
237
|
+
# `complete_state?` comes back false, so dispatch takes the safe
|
|
238
|
+
# `hydrate_existing` path instead of the `ATOMIC_PUT` fast path.
|
|
239
|
+
# Not a correctness bug — `as:`'s runtime binding (a plain `attrs`
|
|
240
|
+
# merge, exactly like `old:`'s) resolves and evaluates correctly
|
|
241
|
+
# regardless of what this STATIC analysis concludes — just a real,
|
|
242
|
+
# deliberately-left optimization gap: closing it would mean
|
|
243
|
+
# threading "which names this command declares as correction
|
|
244
|
+
# bindings" into the Analyzer, which doesn't have that per-command
|
|
245
|
+
# context today. Worth doing alongside `:old`/`:parent`'s own
|
|
246
|
+
# handling someday, not attempted here.
|
|
193
247
|
def classify_path(path, phase)
|
|
194
248
|
head, nested = path.split(".", 2)
|
|
195
249
|
name = head.to_sym
|
|
196
250
|
|
|
197
251
|
if name == :parent
|
|
198
252
|
parent_field = nested.to_s.split(".", 2).first
|
|
199
|
-
|
|
253
|
+
# `root_owner_fields` — NOT `owner_fields`. For an entity-owned
|
|
254
|
+
# command `owner_fields` is the ENTITY's own attribute set;
|
|
255
|
+
# `parent.X` always means the ROOT aggregate's own field, a
|
|
256
|
+
# genuinely different owner (`root_aggregate:`'s own header,
|
|
257
|
+
# above, has the full bug this fixes). Identical for a plain
|
|
258
|
+
# aggregate command, where root_aggregate defaults to aggregate
|
|
259
|
+
# itself and the two sets are the same set.
|
|
260
|
+
if parent_field.empty? || !root_owner_fields.include?(parent_field.to_sym)
|
|
200
261
|
unresolved << "#{path} does not name parent aggregate state"
|
|
201
262
|
else
|
|
202
263
|
state_reads << parent_field.to_sym
|
|
@@ -256,15 +256,30 @@ module Hecks
|
|
|
256
256
|
# is cleared for the reaction's own dispatch, so a triggering
|
|
257
257
|
# caller's role can neither satisfy nor block a reaction command it
|
|
258
258
|
# has nothing to do with (Runtime::Caller.without).
|
|
259
|
+
#
|
|
260
|
+
# `Thread.current[:hecks_reaction_depth]`, not a plain ivar — this
|
|
261
|
+
# `Dispatcher` instance is a single object shared by every thread
|
|
262
|
+
# dispatching through it (a Puma worker pool, say), so a plain ivar
|
|
263
|
+
# here is exactly the known Puma-concurrency bug class: two
|
|
264
|
+
# concurrent top-level dispatches on different threads would
|
|
265
|
+
# increment/decrement the SAME counter, letting one thread's nested
|
|
266
|
+
# reaction depth leak into another thread's unrelated dispatch. A
|
|
267
|
+
# `Mutex` is not the answer either — a reaction cascade re-enters
|
|
268
|
+
# `reenter` on the SAME thread (see `SagaInterpreter#advance_saga`'s
|
|
269
|
+
# own comment on why a non-reentrant `Mutex` can't guard this).
|
|
270
|
+
# `Thread.current`-backed, saved/restored around the call with a
|
|
271
|
+
# plain local + `ensure`, is the same idiom `Runtime::Caller`
|
|
272
|
+
# (`caller.rb`) already established for exactly this shape of
|
|
273
|
+
# per-thread ambient state.
|
|
259
274
|
def reenter(verb, saga_correlation: nil, **args)
|
|
260
|
-
depth =
|
|
261
|
-
|
|
275
|
+
depth = Thread.current[:hecks_reaction_depth].to_i
|
|
276
|
+
Thread.current[:hecks_reaction_depth] = depth + 1
|
|
262
277
|
Caller.without { dispatch(verb, saga_correlation: saga_correlation, **args) }
|
|
263
278
|
ensure
|
|
264
|
-
|
|
279
|
+
Thread.current[:hecks_reaction_depth] = depth
|
|
265
280
|
end
|
|
266
281
|
|
|
267
|
-
def reaction_depth_reached? =
|
|
282
|
+
def reaction_depth_reached? = Thread.current[:hecks_reaction_depth].to_i >= MAX_REACTION_DEPTH
|
|
268
283
|
def max_reaction_depth = MAX_REACTION_DEPTH
|
|
269
284
|
|
|
270
285
|
private
|
|
@@ -10,11 +10,25 @@ require_relative "routing"
|
|
|
10
10
|
require_relative "dependency_planning"
|
|
11
11
|
require_relative "../ports/persistence/execution"
|
|
12
12
|
require_relative "entity_element"
|
|
13
|
+
require_relative "command_interpreter/argument_gate"
|
|
13
14
|
|
|
14
15
|
module Hecks
|
|
15
16
|
module Runtime
|
|
16
17
|
class EntityInterpreter
|
|
17
18
|
include Interpreting
|
|
19
|
+
# THE SAME PAYLOAD GATE aggregate commands and port operations already
|
|
20
|
+
# run — bug audit H1 (docs/audits/2026-08-10-main-bug-audit.md): this
|
|
21
|
+
# class used to run NEITHER refuse_unknown_arguments NOR
|
|
22
|
+
# refuse_absent_arguments, on a comment claiming "an entity inherits
|
|
23
|
+
# its aggregate's own gate." Nothing on the entity dispatch path ever
|
|
24
|
+
# ran one — confirmed live, `LedgerEntry.Reverse` accepted an
|
|
25
|
+
# unrecognized `bogus_arg:` outright, and dispatching it with no
|
|
26
|
+
# `narrative:` silently overwrote the stored narrative with `nil`
|
|
27
|
+
# (`sets :narrative`'s bare self-referential form reads `args[:narrative]`
|
|
28
|
+
# unconditionally). See `step_refuse_unknown_arguments`/
|
|
29
|
+
# `step_refuse_absent_arguments`, below, for how the shared gate is
|
|
30
|
+
# reused rather than reimplemented.
|
|
31
|
+
include CommandInterpreter::ArgumentGate
|
|
18
32
|
|
|
19
33
|
attr_reader :registry
|
|
20
34
|
|
|
@@ -23,13 +37,17 @@ module Hecks
|
|
|
23
37
|
# spec/vocabulary_conformance_spec.rb the same way CommandInterpreter's
|
|
24
38
|
# own DISPATCH_ORDER is; see that constant's doc comment for why this is
|
|
25
39
|
# hand-typed rather than read live off the meta-domain at every dispatch.
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
# assign_creation_attributes (an entity is never created through this
|
|
40
|
+
# `refuse_unknown_arguments`/`refuse_absent_arguments` now lead it, same
|
|
41
|
+
# position `AggregateDispatchOrder` holds them at (H1, above) — the only
|
|
42
|
+
# remaining difference from the aggregate order is no
|
|
43
|
+
# `assign_creation_attributes` (an entity is never created through this
|
|
30
44
|
# path).
|
|
31
45
|
DISPATCH_ORDER = Hecks::Vocabulary.symbols("EntityDispatchOrder")
|
|
32
46
|
|
|
47
|
+
# Same safety valve as `CommandInterpreter::MAX_STALE_WRITE_RETRIES` —
|
|
48
|
+
# see that constant's own comment.
|
|
49
|
+
MAX_STALE_WRITE_RETRIES = 5
|
|
50
|
+
|
|
33
51
|
# `instance` is the PARENT aggregate record (what gets saved and
|
|
34
52
|
# returned) ; `element`/`view` are the entity piece itself — `view`
|
|
35
53
|
# wraps `element` as it stood at `locate_element`, pre-mutation, and
|
|
@@ -56,6 +74,10 @@ module Hecks
|
|
|
56
74
|
# comment for the shared reasoning (Dispatcher#dry_run?'s own entry
|
|
57
75
|
# point). `step_save`/`step_emit` are the only two steps here that
|
|
58
76
|
# read it either.
|
|
77
|
+
# RETRIES THE WHOLE METHOD BODY on `StaleWrite` — same reasoning as
|
|
78
|
+
# `CommandInterpreter#call`'s own retry: a fresh `ctx`, a fresh
|
|
79
|
+
# `step_hydrate_parent`/`step_locate_element` re-reading current
|
|
80
|
+
# state.
|
|
59
81
|
def call(domain, aggregate, dotted, legacy_args, route: nil, with: nil, dry_run: false)
|
|
60
82
|
*entity_names, command_name = dotted.to_s.split(".")
|
|
61
83
|
if entity_names.empty?
|
|
@@ -70,13 +92,31 @@ module Hecks
|
|
|
70
92
|
entity: entity.hecks_name, command: command_name.inspect))
|
|
71
93
|
|
|
72
94
|
args = Routing.payload(command, with: with, legacy: legacy_args)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
95
|
+
attempt = 0
|
|
96
|
+
begin
|
|
97
|
+
ctx = Context.new(domain, aggregate, entity, entity_names.join("."), command, command_name, args)
|
|
98
|
+
ctx.chain = chain
|
|
99
|
+
ctx.route = route
|
|
100
|
+
ctx.dry_run = dry_run
|
|
101
|
+
# `root_aggregate:` — `entity` is the immediate owner (what
|
|
102
|
+
# `owner_fields` inside the Analyzer means), but a `parent.X`
|
|
103
|
+
# read inside this command's own given/ensures means the ROOT
|
|
104
|
+
# aggregate's own field, not the entity's — `aggregate` here IS
|
|
105
|
+
# that root (this method's own first parameter, never the
|
|
106
|
+
# entity). See DependencyPlanning::Analyzer.call's own header
|
|
107
|
+
# for the bug this closes.
|
|
108
|
+
ctx.plan = DependencyPlanning::Analyzer.call(aggregate: entity, command: command, root_aggregate: aggregate)
|
|
109
|
+
# RESOLVED HERE, ONCE — see CommandInterpreter#call's own comment;
|
|
110
|
+
# `step_hydrate_parent` reads `ctx.repository` without re-fetching.
|
|
111
|
+
ctx.repository = @registry.repository(domain, aggregate)
|
|
112
|
+
lock_id = Identity.best_effort(aggregate, args, route)
|
|
113
|
+
run_dispatch_order_with_isolation(DISPATCH_ORDER, ctx, lock_key_id: lock_id)
|
|
114
|
+
[ctx.instance, ctx.result, ctx.plan, ctx.persistence_outcome]
|
|
115
|
+
rescue StaleWrite
|
|
116
|
+
attempt += 1
|
|
117
|
+
retry if attempt < MAX_STALE_WRITE_RETRIES
|
|
118
|
+
raise
|
|
119
|
+
end
|
|
80
120
|
end
|
|
81
121
|
|
|
82
122
|
private
|
|
@@ -100,6 +140,31 @@ module Hecks
|
|
|
100
140
|
end
|
|
101
141
|
end
|
|
102
142
|
|
|
143
|
+
# `extra_identity_heads:` — every entity `ctx.chain` walks through, not
|
|
144
|
+
# just the root aggregate `ArgumentGate` already knows about. A
|
|
145
|
+
# two-hop dispatch (`Handler.Dispatch.Bind`) is addressed by BOTH
|
|
146
|
+
# hops' own identity, each read straight out of `args` by
|
|
147
|
+
# `EntityElement#element_of` — refusing those as unknown would refuse
|
|
148
|
+
# every legitimate nested-entity dispatch there is, the same reasoning
|
|
149
|
+
# `ArgumentGate#refuse_unknown_arguments`'s own header gives for `:id`/
|
|
150
|
+
# the root's `identity_heads`.
|
|
151
|
+
def step_refuse_unknown_arguments(ctx)
|
|
152
|
+
step(:refuse_unknown_arguments) {
|
|
153
|
+
refuse_unknown_arguments(ctx.domain, ctx.aggregate, ctx.command, ctx.args,
|
|
154
|
+
extra_identity_heads: ctx.chain.flat_map(&:identity_heads))
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# No `aggregate:` exemption to pass — that kwarg exists only for a
|
|
159
|
+
# port operation's own self-address (`ArgumentGate#refuse_absent_
|
|
160
|
+
# arguments`'s own comment); an entity command's chain identity never
|
|
161
|
+
# reaches `command.attributes` in the first place (resolved as
|
|
162
|
+
# addressing above, not as a declared fact), so there is nothing here
|
|
163
|
+
# for the exemption to need to strip.
|
|
164
|
+
def step_refuse_absent_arguments(ctx)
|
|
165
|
+
step(:refuse_absent_arguments) { refuse_absent_arguments(ctx.command, ctx.args) }
|
|
166
|
+
end
|
|
167
|
+
|
|
103
168
|
def step_normalize_args(ctx)
|
|
104
169
|
ctx.args = step(:normalize_args) { normalize_args(ctx.aggregate, ctx.command, ctx.args) }
|
|
105
170
|
end
|
|
@@ -113,7 +178,8 @@ module Hecks
|
|
|
113
178
|
end
|
|
114
179
|
|
|
115
180
|
def step_hydrate_parent(ctx)
|
|
116
|
-
ctx.repository
|
|
181
|
+
# `ctx.repository` is resolved once, in `#call`, before the
|
|
182
|
+
# isolation decision — not here any more.
|
|
117
183
|
ctx.instance = step(:hydrate_parent) {
|
|
118
184
|
parent(ctx.repository, ctx.aggregate, ctx.entity_name, ctx.command_name, ctx.args, ctx.route)
|
|
119
185
|
}
|
|
@@ -184,8 +250,20 @@ module Hecks
|
|
|
184
250
|
|
|
185
251
|
step(:save) do
|
|
186
252
|
@rules.resolve_state_references(ctx.domain, ctx.aggregate, ctx.instance.state)
|
|
187
|
-
|
|
188
|
-
|
|
253
|
+
# `expected_version:` — see CommandInterpreter#step_save's own
|
|
254
|
+
# comment: nil for a repository that isn't CAS-capable, or an
|
|
255
|
+
# instance never read from storage, either of which falls
|
|
256
|
+
# through to a plain save inside `AppendOnly#save`.
|
|
257
|
+
ctx.persistence_outcome = ctx.repository.save(ctx.instance, expected_version: ctx.instance.version)
|
|
258
|
+
if ctx.persistence_outcome.status == :stale
|
|
259
|
+
# NOT a `RefusalWording.render` call — see
|
|
260
|
+
# `CommandInterpreter#step_save`'s identical branch and
|
|
261
|
+
# `Runtime::StaleWrite`'s own comment.
|
|
262
|
+
raise(StaleWrite,
|
|
263
|
+
"#{ctx.command.hecks_name} on #{ctx.aggregate.hecks_name} " \
|
|
264
|
+
"(#{Identity.reading(ctx.aggregate)}: #{Rendering.describe(ctx.instance.id)}) lost a race — " \
|
|
265
|
+
"another write committed against this record after it was read")
|
|
266
|
+
end
|
|
189
267
|
end
|
|
190
268
|
end
|
|
191
269
|
|
data/lib/hecks/runtime/errors.rb
CHANGED
|
@@ -51,6 +51,28 @@ module Hecks
|
|
|
51
51
|
# itself, not whether the caller actually holds `policy`. See
|
|
52
52
|
# Runtime::TenantScope.
|
|
53
53
|
class Unauthorized < StandardError; end
|
|
54
|
+
# `corrects` names a past event this record must have already emitted
|
|
55
|
+
# (CommandBuilder#corrects_impl's own comment) — a fact the expression
|
|
56
|
+
# evaluator cannot check (it is not a predicate over the record's OWN
|
|
57
|
+
# fields, it is "did this exact record ever announce this"), so it is
|
|
58
|
+
# raised structurally, the same way AlreadyExists/NotFound are, rather
|
|
59
|
+
# than being expressible as an ordinary `given`. Raised by
|
|
60
|
+
# `CommandRules::Admissibility#enforce_correction_target`.
|
|
61
|
+
class NothingToCorrect < StandardError; end
|
|
62
|
+
|
|
63
|
+
# A RUNTIME FAULT, NOT A DOMAIN REFUSAL — deliberately absent from
|
|
64
|
+
# `DOMAIN_REFUSALS` below and from `vocabulary.bluebook`'s own
|
|
65
|
+
# `DomainRefusal` list. Raised when an optimistic-concurrency CAS write
|
|
66
|
+
# (`AppendOnly#save`'s `expected_version:`) finds the stored version has
|
|
67
|
+
# moved since this instance was read — someone else's write committed
|
|
68
|
+
# in between. `CommandInterpreter#call`/`EntityInterpreter#call` catch
|
|
69
|
+
# this themselves and retry the whole dispatch from a fresh hydrate, so
|
|
70
|
+
# `enforce_givens` re-evaluates against the now-current state; a caller
|
|
71
|
+
# only ever sees this escape when every retry is exhausted under
|
|
72
|
+
# sustained contention — an operational condition (many concurrent
|
|
73
|
+
# writers hammering one aggregate), not a business rule a domain author
|
|
74
|
+
# declared. See docs/decisions/ (concurrency-control ADR).
|
|
75
|
+
class StaleWrite < StandardError; end
|
|
54
76
|
|
|
55
77
|
# A Lambda-routed domain's own refusal (rust/host, `Runtime::
|
|
56
78
|
# RemoteDispatcher`), carrying Rust's own refusal text verbatim —
|
|
@@ -23,6 +23,15 @@ module Hecks
|
|
|
23
23
|
module Identity
|
|
24
24
|
module_function
|
|
25
25
|
|
|
26
|
+
# A hash read that decides which spelling of a key answers by
|
|
27
|
+
# PRESENCE, never by `||` — a bare `||` treats a genuinely-held
|
|
28
|
+
# `false` the same as an absent key and falls through to the other
|
|
29
|
+
# spelling, landing on `nil` instead of the real, stored answer.
|
|
30
|
+
def hash_lookup(hash, key)
|
|
31
|
+
sym = key.to_sym
|
|
32
|
+
hash.key?(sym) ? hash[sym] : hash[key]
|
|
33
|
+
end
|
|
34
|
+
|
|
26
35
|
# The head names the ATTRIBUTE and is consumed by whoever looked the value
|
|
27
36
|
# up; what is left is the walk down into it. A path with no fields to walk
|
|
28
37
|
# — an aggregate that declares no identity and falls back to `id` — hands
|
|
@@ -32,7 +41,7 @@ module Hecks
|
|
|
32
41
|
return held if fields.empty?
|
|
33
42
|
|
|
34
43
|
fields.reduce(Value.materialize(held)) do |dug, field|
|
|
35
|
-
dug.is_a?(Hash) ? (dug
|
|
44
|
+
dug.is_a?(Hash) ? hash_lookup(dug, field) : nil
|
|
36
45
|
end
|
|
37
46
|
end
|
|
38
47
|
|
|
@@ -83,7 +92,7 @@ module Hecks
|
|
|
83
92
|
# arrived whole has to be opened.
|
|
84
93
|
return held.to_s unless held.is_a?(Hash)
|
|
85
94
|
|
|
86
|
-
return rest.reduce(held) { |h, f| h.is_a?(Hash) ? (h
|
|
95
|
+
return rest.reduce(held) { |h, f| h.is_a?(Hash) ? hash_lookup(h, f) : nil }&.to_s
|
|
87
96
|
end
|
|
88
97
|
|
|
89
98
|
# Coerced against the identity ATTRIBUTE only when the caller actually
|
|
@@ -123,6 +132,25 @@ module Hecks
|
|
|
123
132
|
def reading(construct)
|
|
124
133
|
construct.identity_paths.join(", ")
|
|
125
134
|
end
|
|
135
|
+
|
|
136
|
+
# BEST-EFFORT, FOR A LOCK KEY ONLY — `Runtime::AggregateLock`'s own
|
|
137
|
+
# per-record striping needs SOME id to key on before dispatch has run
|
|
138
|
+
# far enough to hydrate for real, so this walks the identical chain
|
|
139
|
+
# `CommandInterpreter#hydrate_existing`/`#hydrate_prior_or_initial`
|
|
140
|
+
# and `EntityInterpreter#parent` already use to locate the real
|
|
141
|
+
# record — but wrapped to never raise. Choosing which Mutex to hold
|
|
142
|
+
# must never itself become a crash. `nil` means "could not resolve
|
|
143
|
+
# from the raw, pre-normalized payload this runs against" — the
|
|
144
|
+
# caller locks by aggregate type alone in that case (coarser, still
|
|
145
|
+
# correct, just less concurrent).
|
|
146
|
+
def best_effort(construct, args, route = nil, reference_key: nil)
|
|
147
|
+
route&.aggregate ||
|
|
148
|
+
of(construct, args) ||
|
|
149
|
+
from(construct, args, :id) ||
|
|
150
|
+
(reference_key && from(construct, args, reference_key))
|
|
151
|
+
rescue StandardError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
126
154
|
end
|
|
127
155
|
end
|
|
128
156
|
end
|
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
require_relative "value"
|
|
2
|
+
require_relative "identity"
|
|
2
3
|
|
|
3
4
|
module Hecks
|
|
4
5
|
module Runtime
|
|
5
6
|
class Instance
|
|
6
7
|
attr_reader :aggregate, :id
|
|
7
8
|
attr_accessor :state
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
# OUT-OF-BAND ADAPTER BOOKKEEPING, NOT DOMAIN STATE — the optimistic-
|
|
10
|
+
# concurrency version a CAS-capable adapter (Postgres today) stamps
|
|
11
|
+
# on a record it reads/writes, so a later `save` can assert "commit
|
|
12
|
+
# only if nobody has written since". Deliberately absent from
|
|
13
|
+
# `to_h`/`[]`/`[]=`/`method_missing` : a domain author never declares
|
|
14
|
+
# this, a `given`/`ensures`/`invariant` can never read it, and no
|
|
15
|
+
# adapter that doesn't understand it (Memory, Heki) ever sets it —
|
|
16
|
+
# `nil` there just means "no CAS attempted", which is exactly what a
|
|
17
|
+
# plain `save` already does. See docs/decisions/ (concurrency-control
|
|
18
|
+
# ADR) for the full mechanism.
|
|
19
|
+
attr_accessor :version
|
|
20
|
+
|
|
21
|
+
# `args:` — THE ORIGINAL COMMAND PAYLOAD, offered only by a fresh
|
|
22
|
+
# creation (`CommandInterpreter#hydrate_legacy_creation`/
|
|
23
|
+
# `#hydrate_complete_state`/`#hydrate_prior_or_initial`, each already
|
|
24
|
+
# holding it when they mint a brand-new record). See
|
|
25
|
+
# `materialize_identity!` for why a composite identity needs it.
|
|
26
|
+
def initialize(aggregate:, id:, state: nil, args: nil)
|
|
10
27
|
@aggregate = aggregate
|
|
11
28
|
@id = id
|
|
12
29
|
@state = state ? self.class.hydrate_with_defaults(aggregate, state) : self.class.defaults(aggregate)
|
|
13
|
-
|
|
30
|
+
@version = nil
|
|
31
|
+
materialize_identity!(args)
|
|
14
32
|
end
|
|
15
33
|
|
|
16
34
|
# Loading existing state runs the same default-fill a fresh instance
|
|
@@ -103,7 +121,29 @@ module Hecks
|
|
|
103
121
|
|
|
104
122
|
private
|
|
105
123
|
|
|
106
|
-
|
|
124
|
+
# M17 — a COMPOSITE identity (`identity_heads.size > 1`, e.g.
|
|
125
|
+
# `identified_by :branch_code, :box_number`) has no single
|
|
126
|
+
# `identified_by` to fall back to `:id` for — `@aggregate.identified_by`
|
|
127
|
+
# is nil the moment there is more than one head (`Behaviour::Identified
|
|
128
|
+
# #derive_identity`), so the single-head branch below never runs for
|
|
129
|
+
# it at all. A creating command that declares those heads as ordinary
|
|
130
|
+
# attributes but doesn't ALSO `sets` them (redundant with the identity
|
|
131
|
+
# the command's own args already named) used to persist every head as
|
|
132
|
+
# nil — the id correctly named the record, but the record's own
|
|
133
|
+
# attributes forgot what named it.
|
|
134
|
+
#
|
|
135
|
+
# Filled from `args`, never from splitting `@id` back apart — the
|
|
136
|
+
# same reason the single-head branch below won't guess a multi-path
|
|
137
|
+
# identifier from its joined string: `@id` is a display key, not a
|
|
138
|
+
# reversible serialization, and a composite's own separator can
|
|
139
|
+
# collide with a part's own text. `args` is only offered by a FRESH
|
|
140
|
+
# creation (`Instance.new`'s own `args:` comment); an existing record
|
|
141
|
+
# read back from storage has no args to lean on, and doesn't need
|
|
142
|
+
# one since a correctly-persisted record already carries its own
|
|
143
|
+
# heads.
|
|
144
|
+
def materialize_identity!(args = nil)
|
|
145
|
+
return materialize_composite_identity!(args) if @aggregate.identity_heads.size > 1
|
|
146
|
+
|
|
107
147
|
identity = @aggregate.identified_by || :id
|
|
108
148
|
attribute = @aggregate.attribute(identity)
|
|
109
149
|
return unless attribute && @state[identity].nil?
|
|
@@ -115,6 +155,21 @@ module Hecks
|
|
|
115
155
|
|
|
116
156
|
@state[identity] = Value.from_identifier(@aggregate, attribute, @id)
|
|
117
157
|
end
|
|
158
|
+
|
|
159
|
+
def materialize_composite_identity!(args)
|
|
160
|
+
return unless args
|
|
161
|
+
|
|
162
|
+
@aggregate.identity_paths.each do |path|
|
|
163
|
+
head = path.to_s.split(".").first.to_sym
|
|
164
|
+
attribute = @aggregate.attribute(head)
|
|
165
|
+
next unless attribute && @state[head].nil?
|
|
166
|
+
|
|
167
|
+
raw = Identity.from(@aggregate, args, path, value_owner: @aggregate)
|
|
168
|
+
next if raw.nil?
|
|
169
|
+
|
|
170
|
+
@state[head] = Value.from_identifier(@aggregate, attribute, raw)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
118
173
|
end
|
|
119
174
|
end
|
|
120
175
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require_relative "value"
|
|
2
|
+
require_relative "aggregate_lock"
|
|
2
3
|
|
|
3
4
|
module Hecks
|
|
4
5
|
module Runtime
|
|
@@ -43,6 +44,26 @@ module Hecks
|
|
|
43
44
|
order.each { |name| send(:"step_#{name}", ctx) }
|
|
44
45
|
end
|
|
45
46
|
|
|
47
|
+
# THE CONCURRENCY-CONTROL SPLIT — see docs/decisions/ (concurrency
|
|
48
|
+
# control ADR) for the full mechanism. A repository that declares
|
|
49
|
+
# `:optimistic_concurrency` (Postgres today) already closes the
|
|
50
|
+
# lost-update gap itself, via `step_save`'s CAS + `#call`'s own
|
|
51
|
+
# `StaleWrite` retry loop — an extra in-process lock here would be
|
|
52
|
+
# pointless overhead, not incorrect, so it's skipped for clarity.
|
|
53
|
+
# Every other repository (Heki, Memory — confirmed process-local
|
|
54
|
+
# data, never a second process writing the same store) gets a
|
|
55
|
+
# striped `Mutex` held for the WHOLE dispatch-order run, so a second
|
|
56
|
+
# thread's own hydrate can't start until the first thread's save has
|
|
57
|
+
# landed. `lock_key_id` is best-effort (`Identity.best_effort`) —
|
|
58
|
+
# `nil` still locks correctly, just coarser (by aggregate type).
|
|
59
|
+
def run_dispatch_order_with_isolation(order, ctx, lock_key_id:)
|
|
60
|
+
if ctx.repository.capabilities.include?(:optimistic_concurrency)
|
|
61
|
+
run_dispatch_order(order, ctx)
|
|
62
|
+
else
|
|
63
|
+
AggregateLock.for(ctx.domain, ctx.aggregate, lock_key_id).synchronize { run_dispatch_order(order, ctx) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
46
67
|
# Every declared attribute present in the payload passes the reference
|
|
47
68
|
# gate, then coercion — the same walk whether the command acts on an
|
|
48
69
|
# aggregate or on one of its entity's elements.
|
data/lib/hecks/runtime/loader.rb
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
require_relative "../facade/surface"
|
|
2
2
|
require_relative "../ports/loading"
|
|
3
|
+
require_relative "../ports/persistence"
|
|
3
4
|
require_relative "dispatcher"
|
|
4
5
|
require_relative "remote_dispatcher"
|
|
5
|
-
require_relative "
|
|
6
|
+
require_relative "boot_gates"
|
|
6
7
|
require_relative "registry"
|
|
7
8
|
|
|
8
9
|
module Hecks
|
|
@@ -42,20 +43,7 @@ module Hecks
|
|
|
42
43
|
loading.load_domain(directory, environment: environment)
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
# era (Postgres) must have created its partition and head views
|
|
47
|
-
# before any adapter opens them, and a refused era must refuse
|
|
48
|
-
# before any adapter touches data.
|
|
49
|
-
EraCheck.check!(registry, directory)
|
|
50
|
-
registry.verify!
|
|
51
|
-
# AFTER verify! (conservative — any wiring error surfaces first,
|
|
52
|
-
# not strictly required since resolution only needs the
|
|
53
|
-
# hecksagon binds, already loaded), BEFORE the dispatcher is
|
|
54
|
-
# built — repopulates `saga_instances` from whatever durable
|
|
55
|
-
# store each domain's own adapter answers with (§2-§4), so a
|
|
56
|
-
# process manager mid-flight at the last shutdown/crash/cold-
|
|
57
|
-
# start doesn't start this boot looking like it never began.
|
|
58
|
-
registry.rehydrate_sagas!
|
|
46
|
+
run_boot_gates!(registry, directory)
|
|
59
47
|
dispatcher = dispatcher_for(registry)
|
|
60
48
|
install_facade ? bind_runtime(dispatcher) : dispatcher
|
|
61
49
|
end
|
|
@@ -95,13 +83,66 @@ module Hecks
|
|
|
95
83
|
loading.load_selected(files, environment: environment)
|
|
96
84
|
end
|
|
97
85
|
|
|
98
|
-
|
|
99
|
-
registry.verify!
|
|
100
|
-
registry.rehydrate_sagas!
|
|
86
|
+
run_boot_gates!(registry, directory)
|
|
101
87
|
dispatcher = dispatcher_for(registry)
|
|
102
88
|
install_facade ? bind_runtime(dispatcher) : dispatcher
|
|
103
89
|
end
|
|
104
90
|
|
|
91
|
+
# ADR 0031 — replaces two previously-hardcoded, unconditional calls
|
|
92
|
+
# with a per-boot `BootGates` instance holding exactly the gates THIS
|
|
93
|
+
# registry's own bound adapters have a capability for. Ordering is
|
|
94
|
+
# preserved: era-checking (when a persistence plugin contributes one)
|
|
95
|
+
# still runs before `verify!`, saga rehydration still runs after
|
|
96
|
+
# (conservative — see `SagaPersistence#rehydrate_sagas!`'s own
|
|
97
|
+
# comment).
|
|
98
|
+
#
|
|
99
|
+
# ADR 0033 — this loader no longer names `EraCheck`, or any other
|
|
100
|
+
# era-specific class, at all. Every LOADED persistence plugin
|
|
101
|
+
# (`Ports::Persistence.each_plugin` — nothing here if nothing was
|
|
102
|
+
# ever `require`d) is asked to contribute its own `:pre_verify`/
|
|
103
|
+
# `:post_verify` gates generically; `:saga_rehydration` is the one
|
|
104
|
+
# gate core still registers directly, because ADR 0031 already
|
|
105
|
+
# proved it's not era-specific.
|
|
106
|
+
def self.run_boot_gates!(registry, directory)
|
|
107
|
+
gates = BootGates.new
|
|
108
|
+
Ports::Persistence.each_plugin { |plugin| plugin.contribute_boot_gates(registry, gates) }
|
|
109
|
+
check_compute_rules_backstop!(registry)
|
|
110
|
+
|
|
111
|
+
gates.run!(:pre_verify, registry, directory)
|
|
112
|
+
registry.verify!
|
|
113
|
+
|
|
114
|
+
gates.register(:saga_rehydration, ->(reg, _dir) { reg.rehydrate_sagas! }, phase: :post_verify) if
|
|
115
|
+
registry.hecksagons.each_key.any? { |domain| registry.saga_persistence(domain) != Ports::Persistence::NULL_SAGA_STORE }
|
|
116
|
+
gates.run!(:post_verify, registry, directory)
|
|
117
|
+
gates
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The one piece of the old, era-owned `check_compute_rules!` core
|
|
121
|
+
# still carries — deliberately thinner. `registry.translations` is
|
|
122
|
+
# plain `Bluebook::Translation`/`TranslationAggregate`/
|
|
123
|
+
# `TranslationCompute`/`TranslationRekey` data (`bluebook/
|
|
124
|
+
# translation.rb`, core, no era-specific class involved), so this
|
|
125
|
+
# needs nothing plugin-specific to ask "does anything declare a
|
|
126
|
+
# compute/rekey rule at all." A LOADED persistence plugin (e.g. the
|
|
127
|
+
# era plugin's own `:era_compute_rules` gate, registered above) runs
|
|
128
|
+
# the real, adapter-aware version of this check and refuses by name
|
|
129
|
+
# ("...is bound to Memory") long before this ever would; this only
|
|
130
|
+
# fires when nothing did, because nothing was loaded to.
|
|
131
|
+
def self.check_compute_rules_backstop!(registry)
|
|
132
|
+
return if Ports::Persistence.plugins_loaded?
|
|
133
|
+
|
|
134
|
+
registry.translations.each do |translation|
|
|
135
|
+
translation.aggregates.each do |aggregate|
|
|
136
|
+
next if aggregate.computes.empty? && aggregate.rekeys.empty?
|
|
137
|
+
|
|
138
|
+
raise WiringError,
|
|
139
|
+
"cannot boot #{translation.domain}::#{aggregate.name}: a compute/rekey rule is declared, but no " \
|
|
140
|
+
"persistence plugin that can interpret it is loaded (e.g. require " \
|
|
141
|
+
"\"hecks/ports/persistence/plugins/era\")"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
105
146
|
# `RemoteDispatcher` for a domain routed through Lambda,
|
|
106
147
|
# `Dispatcher` otherwise — the ONE place this decision gets
|
|
107
148
|
# made, so everything built on top (`Handle`, `AggregateDoor`,
|