hecks 0.3.0 → 1.0.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/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/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/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 +91 -3
- 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/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/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 +132 -5
- data/lib/hecks/bluebook/pattern_subset.rb +66 -2
- data/lib/hecks/bluebook/process_manager.rb +53 -11
- data/lib/hecks/bluebook/value_object.rb +9 -1
- 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/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/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 +97 -13
- data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
- data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
- 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 +14 -0
- data/lib/hecks/runtime/dispatcher.rb +19 -4
- data/lib/hecks/runtime/entity_interpreter.rb +85 -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 +76 -1
- 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 +7 -2
- 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 +48 -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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Runtime
|
|
3
|
+
# A PROCESS-WIDE, STRIPED MUTEX REGISTRY — the concurrency-control
|
|
4
|
+
# mechanism for every adapter that does NOT declare
|
|
5
|
+
# `:optimistic_concurrency` (Heki, Memory today; see
|
|
6
|
+
# `CommandInterpreter#call`/`EntityInterpreter#call`, which choose
|
|
7
|
+
# between this and Postgres's CAS+retry purely off
|
|
8
|
+
# `repository.capabilities`).
|
|
9
|
+
#
|
|
10
|
+
# WHY A LOCK SUFFICES HERE AND CAS IS NOT NEEDED: both adapters hold
|
|
11
|
+
# process-local data. `Adapters::Memory.tenant_capable?`'s own comment
|
|
12
|
+
# states the confirmed fact this relies on — two `Runtime.boot` calls
|
|
13
|
+
# get two entirely separate adapter instances; there is never a SECOND
|
|
14
|
+
# PROCESS writing the same Heki file or the same Memory Hash, only
|
|
15
|
+
# possibly other THREADS within this one process. A `Mutex` held for
|
|
16
|
+
# the full hydrate-through-save critical section closes the identical
|
|
17
|
+
# lost-update gap CAS closes for Postgres, with no schema, no version
|
|
18
|
+
# column, and no retry loop — the second thread simply doesn't start
|
|
19
|
+
# its own hydrate until the first thread's save has landed.
|
|
20
|
+
#
|
|
21
|
+
# STRIPED, NOT ONE GLOBAL LOCK: keyed by `[domain, aggregate.hecks_name,
|
|
22
|
+
# id]`, so two dispatches against two DIFFERENT records never block
|
|
23
|
+
# each other. The registry Hash itself is guarded by its own top-level
|
|
24
|
+
# Mutex only for the moment a new per-key Mutex is created — two
|
|
25
|
+
# threads locking DIFFERENT keys for the first time never wait on one
|
|
26
|
+
# another beyond that brief creation window.
|
|
27
|
+
module AggregateLock
|
|
28
|
+
@registry_lock = Mutex.new
|
|
29
|
+
@locks = {}
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# `AggregateLock.for(domain, aggregate, id).synchronize { ... }`
|
|
33
|
+
# `id: nil` — identity could not be resolved yet (see
|
|
34
|
+
# `Identity.best_effort`) — locks by aggregate TYPE alone, coarser
|
|
35
|
+
# (every record of this aggregate serializes against every other)
|
|
36
|
+
# but still correct: it can only ever make dispatch MORE
|
|
37
|
+
# conservative than a resolved id would.
|
|
38
|
+
def for(domain, aggregate, id = nil)
|
|
39
|
+
key = id.nil? ? [domain.to_s, aggregate.hecks_name] : [domain.to_s, aggregate.hecks_name, id.to_s]
|
|
40
|
+
@registry_lock.synchronize { @locks[key] ||= Mutex.new }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Runtime
|
|
3
|
+
# A boot's own small set of phase-tagged, conditionally-present gates —
|
|
4
|
+
# ADR 0031. Registration is instance-scoped, ONE PER `Loader.boot`
|
|
5
|
+
# CALL, never a module-level singleton: a process that boots more than
|
|
6
|
+
# one registry in its lifetime (every spec suite does) must never let
|
|
7
|
+
# one boot's capability profile leak into the next boot's gate list.
|
|
8
|
+
#
|
|
9
|
+
# A gate is anything `.call(registry, directory)`-able — an existing
|
|
10
|
+
# module method handed over as a `Method` object (`EraCheck.method
|
|
11
|
+
# (:check_lineage!)`) needs no wrapper; a bare block does. Phases run
|
|
12
|
+
# in the order `run!` is called, gates within a phase in registration
|
|
13
|
+
# order — today exactly one gate per phase, so ordering among
|
|
14
|
+
# same-phase gates has never been exercised.
|
|
15
|
+
#
|
|
16
|
+
# This is deliberately NOT the same registry `Hecks::Projector` uses
|
|
17
|
+
# (ADR 0027) — that one is a process-wide, static IR-in/artifact-out
|
|
18
|
+
# registry with no bindings and no live-state mutation; this one is
|
|
19
|
+
# per-boot and gates real I/O (a Postgres mint, a saga-store read).
|
|
20
|
+
# Sharing a primitive between them is deferred until a third consumer
|
|
21
|
+
# actually wants it (0031's own Rejected Alternatives).
|
|
22
|
+
class BootGates
|
|
23
|
+
def initialize
|
|
24
|
+
@gates = Hash.new { |h, k| h[k] = [] }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def register(name, gate, phase:)
|
|
28
|
+
@gates[phase] << [name, gate]
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def registered?(name)
|
|
33
|
+
@gates.values.flatten(1).any? { |registered_name, _gate| registered_name == name }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run!(phase, registry, directory)
|
|
37
|
+
@gates[phase].each { |pair| pair.last.call(registry, directory) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/hecks/runtime/caller.rb
CHANGED
|
@@ -19,15 +19,31 @@ module Hecks
|
|
|
19
19
|
# `CommandRules::Authorization` check a real Governance
|
|
20
20
|
# `RoleAssignment` instead, once the command's domain has Governance
|
|
21
21
|
# attached — see that module's own header for the full split.
|
|
22
|
-
|
|
22
|
+
#
|
|
23
|
+
# `as_of` and `scope` are BOTH optional too, and both self-asserted
|
|
24
|
+
# by the caller rather than derived from the command — deliberately:
|
|
25
|
+
# `as_of` is filled at the door from `Ports::Clock.now`, never
|
|
26
|
+
# inside the interpreter (see `Ports::Clock`'s own header), so
|
|
27
|
+
# there is no other place to source it from. `scope` stays here
|
|
28
|
+
# rather than becoming a command-level DSL construct on purpose —
|
|
29
|
+
# a scope check that lived in the bluebook would put an
|
|
30
|
+
# authorization concern inside the domain declaration itself; this
|
|
31
|
+
# keeps it an application-boundary fact instead, the same shape
|
|
32
|
+
# `role`/`actor_id` already are. The tradeoff: a caller states what
|
|
33
|
+
# scope it is acting in, and `holds_role?` verifies a live grant
|
|
34
|
+
# exists for THAT scope — it does not independently confirm the
|
|
35
|
+
# scope matches whatever the command's own target data belongs to.
|
|
36
|
+
Current = Struct.new(:role, :actor_id, :as_of, :scope, keyword_init: true)
|
|
23
37
|
|
|
24
38
|
module_function
|
|
25
39
|
|
|
26
40
|
def current = Thread.current[:hecks_caller]
|
|
27
41
|
|
|
28
|
-
def as(role:, actor_id: nil)
|
|
42
|
+
def as(role:, actor_id: nil, as_of: nil, scope: nil)
|
|
29
43
|
previous = Thread.current[:hecks_caller]
|
|
30
|
-
Thread.current[:hecks_caller] = Current.new(
|
|
44
|
+
Thread.current[:hecks_caller] = Current.new(
|
|
45
|
+
role: role.to_s, actor_id: actor_id&.to_s, as_of: as_of, scope: scope&.to_s
|
|
46
|
+
)
|
|
31
47
|
yield
|
|
32
48
|
ensure
|
|
33
49
|
Thread.current[:hecks_caller] = previous
|
|
@@ -19,8 +19,19 @@ module Hecks
|
|
|
19
19
|
# the aggregate rather than describe it : `id`, whatever the aggregate is
|
|
20
20
|
# identified by, and the reference key of the root a command reaches
|
|
21
21
|
# through. Refusing those would refuse every dispatch there is.
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
#
|
|
23
|
+
# `extra_identity_heads:` — EntityInterpreter's own callers only. An
|
|
24
|
+
# entity dispatch addresses not just the root aggregate but every
|
|
25
|
+
# entity ALONG THE CHAIN it walks to reach the piece the command
|
|
26
|
+
# actually belongs to (`Handler.Dispatch.Bind` is two hops), and each
|
|
27
|
+
# hop's own `identity_heads` is addressing the same way the root's
|
|
28
|
+
# are — `element_of` (entity_element.rb) reads them straight out of
|
|
29
|
+
# `args` to find the element, never as a fact the command itself
|
|
30
|
+
# declares. `[]` for a plain aggregate/port-operation dispatch, which
|
|
31
|
+
# has no chain to add.
|
|
32
|
+
def refuse_unknown_arguments(domain, aggregate, command, args, extra_identity_heads: [])
|
|
33
|
+
addressing = [:id, *aggregate.identity_heads, *extra_identity_heads, reference_key(command)] +
|
|
34
|
+
correlation_keys(domain)
|
|
24
35
|
known = (command.attributes.map(&:name) + addressing).compact.map(&:to_sym)
|
|
25
36
|
# SORTED. Payload order is whatever the caller happened to write, and
|
|
26
37
|
# refusal wording is contract — pinned byte-for-byte by the corpus, so
|
|
@@ -71,6 +71,18 @@ module Hecks
|
|
|
71
71
|
# never through this method.
|
|
72
72
|
when :delegate
|
|
73
73
|
nil
|
|
74
|
+
# `corrects` — CommandBuilder#corrects_impl's own comment gives
|
|
75
|
+
# the full reasoning for storing it as a mutation at all. A REAL
|
|
76
|
+
# no-op here too: it targets no field on THIS instance either —
|
|
77
|
+
# its own event name, and whether THIS record has actually
|
|
78
|
+
# emitted it, is checked once, up front, by
|
|
79
|
+
# CommandRules::Admissibility#enforce_correction_target, not
|
|
80
|
+
# here. Whatever field this correction actually changes is an
|
|
81
|
+
# ORDINARY declared (or, for `reverses: true`, derived — see
|
|
82
|
+
# AggregateBuilder#seal_correction_targets) mutation of its own,
|
|
83
|
+
# applied by one of the branches above like any other.
|
|
84
|
+
when :corrects
|
|
85
|
+
nil
|
|
74
86
|
else
|
|
75
87
|
# Every declared op has a `when` above — this is not a real
|
|
76
88
|
# runtime path today, only a backstop against the day one
|
|
@@ -9,6 +9,7 @@ require_relative "../ports/persistence/execution"
|
|
|
9
9
|
require_relative "instance"
|
|
10
10
|
require_relative "refusal_wording"
|
|
11
11
|
require_relative "entity_element"
|
|
12
|
+
require_relative "rebuild_sweep"
|
|
12
13
|
|
|
13
14
|
module Hecks
|
|
14
15
|
module Runtime
|
|
@@ -31,6 +32,16 @@ module Hecks
|
|
|
31
32
|
# Runtime::RefusalWording's own doc comment gives the same reason.
|
|
32
33
|
DISPATCH_ORDER = Hecks::Vocabulary.symbols("AggregateDispatchOrder")
|
|
33
34
|
|
|
35
|
+
# A LAST-RESORT SAFETY VALVE, NOT THE NORMAL OUTCOME PATH — see
|
|
36
|
+
# `Runtime::StaleWrite`'s own comment. Two concurrent writers
|
|
37
|
+
# against one aggregate resolve through exactly one retry in the
|
|
38
|
+
# ordinary case (the loser's retried hydrate reads the winner's now-
|
|
39
|
+
# committed state and its own `given` refuses for real, raising
|
|
40
|
+
# `GivenNotMet`, not `StaleWrite`) — this cap exists for pathological
|
|
41
|
+
# contention (many concurrent writers on one hot aggregate), not the
|
|
42
|
+
# two-writer case.
|
|
43
|
+
MAX_STALE_WRITE_RETRIES = 5
|
|
44
|
+
|
|
34
45
|
# EVERY CROSS-STEP LOCAL `call` used to thread through its own literal
|
|
35
46
|
# sequence, held in one place now that the sequence is data-driven —
|
|
36
47
|
# `result` and `transition`/`old_state` default to nil until the step
|
|
@@ -49,14 +60,34 @@ module Hecks
|
|
|
49
60
|
# dispatch would (givens checked, mutations applied to `ctx.instance`
|
|
50
61
|
# in memory); `step_save`/`step_emit` are the only two that read this
|
|
51
62
|
# flag, each skipping its own real work — see their own comments.
|
|
63
|
+
# RETRIES THE WHOLE METHOD BODY on `StaleWrite` — a fresh `ctx`, a
|
|
64
|
+
# fresh `step_hydrate` re-reading current state, so `enforce_givens`
|
|
65
|
+
# re-evaluates against reality rather than the snapshot that just
|
|
66
|
+
# went stale. See `MAX_STALE_WRITE_RETRIES`/`Runtime::StaleWrite`
|
|
67
|
+
# for why exhaustion is a pathological-contention signal, not the
|
|
68
|
+
# expected shape of a two-writer race.
|
|
52
69
|
def call(domain, aggregate, command, args, correlation = nil, route: nil, dry_run: false)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
attempt = 0
|
|
71
|
+
begin
|
|
72
|
+
ctx = Context.new(domain, aggregate, command, args)
|
|
73
|
+
ctx.correlation = correlation
|
|
74
|
+
ctx.route = route
|
|
75
|
+
ctx.dry_run = dry_run
|
|
76
|
+
ctx.plan = DependencyPlanning::Analyzer.call(aggregate: aggregate, command: command)
|
|
77
|
+
# RESOLVED HERE, ONCE, BEFORE HYDRATION — `Registry#repository`
|
|
78
|
+
# memoizes, so this and `step_hydrate`'s own read of `ctx.repository`
|
|
79
|
+
# (no second fetch there any more) always name the same instance;
|
|
80
|
+
# the isolation decision below (lock vs. CAS+retry) needs the
|
|
81
|
+
# repository's capabilities before a single step runs.
|
|
82
|
+
ctx.repository = @registry.repository(domain, aggregate)
|
|
83
|
+
lock_id = Identity.best_effort(aggregate, args, route, reference_key: reference_key(command))
|
|
84
|
+
run_dispatch_order_with_isolation(DISPATCH_ORDER, ctx, lock_key_id: lock_id)
|
|
85
|
+
[ctx.instance, ctx.result, ctx.plan, ctx.persistence_outcome]
|
|
86
|
+
rescue StaleWrite
|
|
87
|
+
attempt += 1
|
|
88
|
+
retry if attempt < MAX_STALE_WRITE_RETRIES
|
|
89
|
+
raise
|
|
90
|
+
end
|
|
60
91
|
end
|
|
61
92
|
|
|
62
93
|
private
|
|
@@ -82,7 +113,8 @@ module Hecks
|
|
|
82
113
|
end
|
|
83
114
|
|
|
84
115
|
def step_hydrate(ctx)
|
|
85
|
-
ctx.repository
|
|
116
|
+
# `ctx.repository` is resolved once, in `#call`, before the
|
|
117
|
+
# isolation decision (lock vs. CAS+retry) — not here any more.
|
|
86
118
|
ctx.strategy = ctx.plan.strategy_for(capabilities: ctx.repository.capabilities)
|
|
87
119
|
ctx.instance = step(:hydrate) {
|
|
88
120
|
if ctx.plan.complete_state? && ctx.plan.state_independent?
|
|
@@ -99,6 +131,12 @@ module Hecks
|
|
|
99
131
|
|
|
100
132
|
def step_enforce_givens(ctx)
|
|
101
133
|
step(:enforce_givens) {
|
|
134
|
+
# STRUCTURAL, before the declared givens — the same ordering
|
|
135
|
+
# NotFound/AlreadyExists already get at hydration: "does the
|
|
136
|
+
# fact this command's corrects names even exist" is not a
|
|
137
|
+
# domain rule an author wrote, it is a precondition for the
|
|
138
|
+
# domain rules to mean anything at all.
|
|
139
|
+
@rules.enforce_correction_target(ctx.instance, ctx.aggregate, ctx.command, domain: ctx.domain)
|
|
102
140
|
@rules.enforce_givens(ctx.instance, ctx.command, ctx.args, domain: ctx.domain,
|
|
103
141
|
declaring: ctx.aggregate, parent: ctx.instance)
|
|
104
142
|
}
|
|
@@ -219,6 +257,7 @@ module Hecks
|
|
|
219
257
|
|
|
220
258
|
step(:save) do
|
|
221
259
|
@rules.resolve_state_references(ctx.domain, ctx.aggregate, ctx.instance.state)
|
|
260
|
+
seed_projected_fields(ctx)
|
|
222
261
|
ctx.persistence_outcome = if ctx.strategy == DependencyPlanning::ATOMIC_PUT
|
|
223
262
|
# A SECOND CREATION IS NOT A FRESH ONE — see
|
|
224
263
|
# hydrate_legacy_creation's own comment; the
|
|
@@ -233,18 +272,63 @@ module Hecks
|
|
|
233
272
|
# this strategy exists to skip.
|
|
234
273
|
ctx.repository.atomic_put(ctx.instance, insert_only: ctx.command.creates?)
|
|
235
274
|
else
|
|
236
|
-
ctx.
|
|
237
|
-
|
|
275
|
+
# `expected_version:` is `ctx.instance.version` — nil for a
|
|
276
|
+
# brand-new record (never read from storage) or when the
|
|
277
|
+
# repository isn't CAS-capable, either of which falls straight
|
|
278
|
+
# through to a plain, unconditional save inside `AppendOnly#save`.
|
|
279
|
+
ctx.repository.save(ctx.instance, expected_version: ctx.instance.version)
|
|
238
280
|
end
|
|
239
281
|
if ctx.persistence_outcome.status == :conflicted
|
|
240
282
|
raise(AlreadyExists, RefusalWording.render("AlreadyExists", "creating_duplicate",
|
|
241
283
|
command: ctx.command.hecks_name, aggregate: ctx.aggregate.hecks_name,
|
|
242
284
|
identity: identity_reading(ctx.aggregate),
|
|
243
285
|
offered: Rendering.describe(ctx.instance.id)))
|
|
286
|
+
elsif ctx.persistence_outcome.status == :stale
|
|
287
|
+
# NOT a `RefusalWording.render` call — this is not a declared
|
|
288
|
+
# vocabulary refusal, just a plain, informative message. See
|
|
289
|
+
# `Runtime::StaleWrite`'s own comment: caught by `#call`'s
|
|
290
|
+
# retry loop, re-raised only once retries are exhausted.
|
|
291
|
+
raise(StaleWrite,
|
|
292
|
+
"#{ctx.command.hecks_name} on #{ctx.aggregate.hecks_name} " \
|
|
293
|
+
"(#{identity_reading(ctx.aggregate)}: #{Rendering.describe(ctx.instance.id)}) lost a race — " \
|
|
294
|
+
"another write committed against this record after it was read")
|
|
244
295
|
end
|
|
245
296
|
end
|
|
246
297
|
end
|
|
247
298
|
|
|
299
|
+
# THE ONE-TIME, SYNCHRONOUS HALF OF `projects` (S12, ADR 0025) —
|
|
300
|
+
# `RebuildSweep` (`runtime/rebuild_sweep.rb`) is deliberately the
|
|
301
|
+
# ONLY thing that keeps a projected field current against a
|
|
302
|
+
# target that changes AFTER this record was written — no reactive
|
|
303
|
+
# `Policy#for_each` keeping it live in real time, that stays
|
|
304
|
+
# deferred, same as that file's own header explains. But without
|
|
305
|
+
# SOME synchronous population, a projected field never gets an
|
|
306
|
+
# INITIAL value at all until an operator remembers to run a
|
|
307
|
+
# sweep by hand — every acting command reading it (`given
|
|
308
|
+
# ("customer is active") { customer_status == "active" }`, say)
|
|
309
|
+
# would refuse a freshly created, genuinely active record for no
|
|
310
|
+
# real reason, which is not the eventual-consistency tradeoff the
|
|
311
|
+
# ADR accepts, just a bug. So: every time a record with `projects`
|
|
312
|
+
# fields is about to save — creating or acting, either can be the
|
|
313
|
+
# first time a referenced record resolves — read each one ONCE,
|
|
314
|
+
# here, using the exact same `RebuildSweep.remote_value` a sweep
|
|
315
|
+
# itself would compute. This is still eventually consistent in
|
|
316
|
+
# the sense the ADR means: a change on the TARGET side after this
|
|
317
|
+
# save still needs a sweep to reach here. It is only ever
|
|
318
|
+
# SYNCHRONOUS with THIS record's own write, never a live read
|
|
319
|
+
# triggered by a `given`/`ensures`/`invariant` mid-dispatch — the
|
|
320
|
+
# boundary rule those enforce holds exactly as before.
|
|
321
|
+
def seed_projected_fields(ctx)
|
|
322
|
+
return if ctx.aggregate.projected_fields.empty?
|
|
323
|
+
|
|
324
|
+
ctx.aggregate.projected_fields.each do |field|
|
|
325
|
+
value = RebuildSweep.remote_value(@registry, ctx.domain, ctx.aggregate, ctx.instance.state, field)
|
|
326
|
+
next if value.nil?
|
|
327
|
+
|
|
328
|
+
ctx.instance.state[field.name] = value
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
248
332
|
# A DELEGATING COMMAND EMITS NOTHING OF ITS OWN (`CommandBuilder#build`'s
|
|
249
333
|
# own guard refuses declaring `emits` alongside `delegates_to`) — its
|
|
250
334
|
# result IS whatever `step_delegate_to_entity` already collected from
|
|
@@ -321,7 +405,7 @@ module Hecks
|
|
|
321
405
|
offered: Rendering.describe(id)))
|
|
322
406
|
end
|
|
323
407
|
|
|
324
|
-
Instance.new(aggregate: aggregate, id: id)
|
|
408
|
+
Instance.new(aggregate: aggregate, id: id, args: args)
|
|
325
409
|
end
|
|
326
410
|
|
|
327
411
|
def hydrate_complete_state(repository, aggregate, command, args, route, strategy)
|
|
@@ -355,7 +439,7 @@ module Hecks
|
|
|
355
439
|
offered: Rendering.describe(id)))
|
|
356
440
|
end
|
|
357
441
|
|
|
358
|
-
Instance.new(aggregate: aggregate, id: id)
|
|
442
|
+
Instance.new(aggregate: aggregate, id: id, args: args)
|
|
359
443
|
end
|
|
360
444
|
|
|
361
445
|
# A complete command may still depend on prior state: lifecycle guards
|
|
@@ -398,7 +482,7 @@ module Hecks
|
|
|
398
482
|
offered: Rendering.describe(id)))
|
|
399
483
|
end
|
|
400
484
|
|
|
401
|
-
found ? found.dup : Instance.new(aggregate: aggregate, id: id)
|
|
485
|
+
found ? found.dup : Instance.new(aggregate: aggregate, id: id, args: args)
|
|
402
486
|
end
|
|
403
487
|
|
|
404
488
|
# THE JOIN, THE DIG, AND THE READING — all shared with `EntityInterpreter`
|
|
@@ -118,9 +118,23 @@ module Hecks
|
|
|
118
118
|
# nil for an aggregate command — CommandInterpreter never passes it.
|
|
119
119
|
def enforce_givens(subject, command, args, domain:, declaring: nil, parent: nil)
|
|
120
120
|
state = GuardState.new(subject)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
# A RULE MAY ONLY READ WITHIN ITS OWN AGGREGATE BOUNDARY (S12,
|
|
122
|
+
# ADR 0025) — `subject`'s own STORED references are no longer
|
|
123
|
+
# dereferenced here at all. What used to be a live query against
|
|
124
|
+
# another aggregate's own repository is now just `subject`'s own
|
|
125
|
+
# state: a `projects :customer_status, from: :"customer.status"`
|
|
126
|
+
# field is a REGULAR stored attribute, already present in
|
|
127
|
+
# `subject`/`state` with no hydration step needed. `dereference`
|
|
128
|
+
# is still called on `command`/`args`, below — that is a
|
|
129
|
+
# DIFFERENT case the ADR explicitly keeps in bounds ("its command
|
|
130
|
+
# arguments"): a reference-typed ARGUMENT this dispatch was just
|
|
131
|
+
# handed (`Dispute`'s own `disputed_by`, say) has nothing stored
|
|
132
|
+
# to project yet, so resolving it here, once, synchronously with
|
|
133
|
+
# THIS command's own admission, is not the live-query-against-
|
|
134
|
+
# another-aggregate's-stored-state pattern the boundary rule
|
|
135
|
+
# forbids.
|
|
136
|
+
attrs = args.merge(dereference(domain, command, args))
|
|
137
|
+
attrs = attrs.merge(parent: parent.state) if parent
|
|
124
138
|
command.givens.each do |given|
|
|
125
139
|
next if Bluebook::Expression::Evaluator.call(given.canonical, state, attrs)
|
|
126
140
|
|
|
@@ -130,6 +144,32 @@ module Hecks
|
|
|
130
144
|
enforce_lifecycle_guard(declaring, command, subject) if declaring
|
|
131
145
|
end
|
|
132
146
|
|
|
147
|
+
# `corrects` — CommandBuilder#corrects_impl's own comment. NOT
|
|
148
|
+
# expressible as an ordinary `given`: "has this exact record
|
|
149
|
+
# already emitted this exact event" is not a predicate over the
|
|
150
|
+
# record's OWN fields, it is a fact about the event log, so it is
|
|
151
|
+
# raised structurally here, the same way NotFound/AlreadyExists
|
|
152
|
+
# are, rather than through the expression evaluator. The build-
|
|
153
|
+
# time half — does ANYTHING in this aggregate ever emit the named
|
|
154
|
+
# event at all — is `AggregateBuilder#seal_correction_targets`;
|
|
155
|
+
# this is the dispatch-time half — has THIS record actually done
|
|
156
|
+
# so yet.
|
|
157
|
+
def enforce_correction_target(instance, aggregate, command, domain:)
|
|
158
|
+
command.mutations.each do |mutation|
|
|
159
|
+
next unless mutation.op == :corrects
|
|
160
|
+
|
|
161
|
+
event_key = "#{domain}::#{aggregate.hecks_name}"
|
|
162
|
+
event_name = mutation.target.to_s
|
|
163
|
+
next if @registry.event_log.any? do |event|
|
|
164
|
+
event.name == event_name && event.aggregate == event_key && event.id == instance.id
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
raise NothingToCorrect,
|
|
168
|
+
"#{command.hecks_name} refused — corrects #{event_name}, but " \
|
|
169
|
+
"#{event_key} ##{instance.id} has never emitted it"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
133
173
|
# LIFECYCLE STATE AS A COMMAND GUARD (S10, ADR 0025) — `command
|
|
134
174
|
# "Debit", from: "open"` checked here, folded into the SAME
|
|
135
175
|
# dispatch step `given` already runs at (both are preconditions,
|
|
@@ -177,13 +217,14 @@ module Hecks
|
|
|
177
217
|
# field the command just took in to mutate it.
|
|
178
218
|
def enforce_ensures(subject, command, args, old:, domain:, parent: nil)
|
|
179
219
|
state = GuardState.new(subject)
|
|
180
|
-
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
|
|
186
|
-
attrs =
|
|
220
|
+
# S12, ADR 0025 — same boundary reasoning as enforce_givens
|
|
221
|
+
# above: `subject`'s own stored references are no longer
|
|
222
|
+
# dereferenced here; a `projects`-maintained field is already
|
|
223
|
+
# part of `state`. `command`/`args` still dereferences — a
|
|
224
|
+
# fresh reference-typed ARGUMENT stays in bounds.
|
|
225
|
+
# `old` still wins over everything, unchanged.
|
|
226
|
+
attrs = args.merge(dereference(domain, command, args))
|
|
227
|
+
attrs = attrs.merge(parent: parent.state) if parent
|
|
187
228
|
attrs = attrs.merge(old: old)
|
|
188
229
|
command.ensures.each do |rule|
|
|
189
230
|
next if Bluebook::Expression::Evaluator.call(rule.canonical, state, attrs)
|
|
@@ -197,16 +238,23 @@ module Hecks
|
|
|
197
238
|
# ensures` already checks at, and for the same reason: an
|
|
198
239
|
# invariant is a claim about the SETTLED record, not the
|
|
199
240
|
# command that produced it, so it reads no `args`/`old` at all,
|
|
200
|
-
# only the record's own
|
|
241
|
+
# only the record's own state. `subject` here is
|
|
201
242
|
# always the AGGREGATE's own instance — `CommandInterpreter`
|
|
202
243
|
# passes its own `ctx.instance`, and `EntityInterpreter` passes
|
|
203
244
|
# the PARENT record (`ctx.instance`, not the element), since an
|
|
204
245
|
# entity mutation changes data inside the SAME aggregate
|
|
205
246
|
# boundary the invariant guards; there is no separate "entity
|
|
206
247
|
# invariant" to check the piece's own view against.
|
|
248
|
+
#
|
|
249
|
+
# NO `dereference` (S12, ADR 0025) — an invariant may only read
|
|
250
|
+
# `subject`'s own boundary, same rule `enforce_givens`/
|
|
251
|
+
# `enforce_ensures` now hold to. No invariant in the corpus has
|
|
252
|
+
# ever read across a `reference_to` (verified before this
|
|
253
|
+
# change), so this is not a migration, just closing the same
|
|
254
|
+
# capability off here that was already unused.
|
|
207
255
|
def enforce_invariants(subject, aggregate, domain:)
|
|
208
256
|
state = GuardState.new(subject)
|
|
209
|
-
attrs =
|
|
257
|
+
attrs = {}
|
|
210
258
|
aggregate.invariants.each do |invariant|
|
|
211
259
|
next if Bluebook::Expression::Evaluator.call(invariant.canonical, state, attrs)
|
|
212
260
|
|
|
@@ -247,8 +295,10 @@ module Hecks
|
|
|
247
295
|
Array(owner_instance[list_attr.name]).each do |element|
|
|
248
296
|
wrapped = Instance.new(aggregate: entity, id: nil, state: element)
|
|
249
297
|
element_state = GuardState.new(wrapped)
|
|
250
|
-
|
|
251
|
-
|
|
298
|
+
# NO `dereference` (S12, ADR 0025) — same boundary rule as
|
|
299
|
+
# enforce_invariants above; `parent` (the owner's own
|
|
300
|
+
# state, projected fields included) stays readable.
|
|
301
|
+
attrs = { parent: owner_instance.state }
|
|
252
302
|
|
|
253
303
|
entity.invariants.each do |invariant|
|
|
254
304
|
next if Bluebook::Expression::Evaluator.call(invariant.canonical, element_state, attrs)
|
|
@@ -45,7 +45,13 @@ module Hecks
|
|
|
45
45
|
# what Vocabulary::MutationOp declares — MutationApplier's own
|
|
46
46
|
# `when :delegate` branch (mutation_applier.rb) never calls
|
|
47
47
|
# #sign_of either, same as `remove`'s own note above.
|
|
48
|
-
MutationOp.new(name: "delegate", sign: nil)
|
|
48
|
+
MutationOp.new(name: "delegate", sign: nil),
|
|
49
|
+
# CommandBuilder#corrects_impl's own comment gives the full
|
|
50
|
+
# reasoning — a command amending a past event rather than
|
|
51
|
+
# acting fresh. Carries no sign, like delegate: it does no
|
|
52
|
+
# arithmetic of its own; the record's actual change, if any,
|
|
53
|
+
# is an ordinary `sets` declared alongside it.
|
|
54
|
+
MutationOp.new(name: "corrects", sign: nil)
|
|
49
55
|
].freeze
|
|
50
56
|
|
|
51
57
|
# A mutation's source is either the NAME OF AN ARGUMENT or a LITERAL, and
|
|
@@ -37,7 +37,8 @@ module Hecks
|
|
|
37
37
|
|
|
38
38
|
authorized =
|
|
39
39
|
if caller.actor_id && governance_attached?(domain)
|
|
40
|
-
Ports::Authorization.holds_role?(registry, actor_id: caller.actor_id, role: command.role
|
|
40
|
+
Ports::Authorization.holds_role?(registry, actor_id: caller.actor_id, role: command.role,
|
|
41
|
+
as_of: caller.as_of, scope: caller.scope)
|
|
41
42
|
else
|
|
42
43
|
caller.role == command.role
|
|
43
44
|
end
|
|
@@ -128,31 +128,39 @@ module Hecks
|
|
|
128
128
|
unwrapped.to_s
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
-
# A
|
|
132
|
-
# `ensures` — `
|
|
131
|
+
# A COMMAND ARGUMENT's own related record, reachable by name from
|
|
132
|
+
# `given`/`ensures` — `disputed_by.status`, say, `CardPayment
|
|
133
|
+
# .Dispute`'s own fresh `Reference<Customer>` argument — without
|
|
133
134
|
# teaching the pure expression evaluator anything about
|
|
134
135
|
# repositories. The lookup happens HERE, once, before evaluation;
|
|
135
136
|
# `Resolver#lookup` just digs into a plain Hash exactly as it
|
|
136
137
|
# always has.
|
|
137
138
|
#
|
|
138
|
-
# `owner`
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
#
|
|
139
|
+
# `owner` NARROWED TO `command` ONLY (S12, ADR 0025 — "rules
|
|
140
|
+
# confined to their own aggregate boundary"): dereferencing the
|
|
141
|
+
# DECLARING aggregate/entity's own STORED `reference_to` used to
|
|
142
|
+
# be the other half of this method's job — a live query against
|
|
143
|
+
# another aggregate's own repository, every time a `given`/
|
|
144
|
+
# `ensures`/`invariant` ran. That half is gone; a cross-aggregate
|
|
145
|
+
# fact a rule needs now has to be a `projects`-maintained LOCAL
|
|
146
|
+
# field (`AggregateBuilder#projects_impl`'s own comment), already
|
|
147
|
+
# present in `subject`'s own state, no hydration needed. A
|
|
148
|
+
# reference-typed COMMAND ARGUMENT stays in bounds, though — the
|
|
149
|
+
# ADR's own boundary list names "its command arguments" as
|
|
150
|
+
# readable, and nothing is stored yet for a fresh argument to
|
|
151
|
+
# project from; resolving it once here, synchronous with THIS
|
|
152
|
+
# command's own admission, is a different shape from a live query
|
|
153
|
+
# against an ALREADY-PERSISTED reference. `enforce_givens`/
|
|
154
|
+
# `enforce_ensures` are this method's only two remaining callers,
|
|
155
|
+
# both passing `command`/`args`, never a `subject`'s own
|
|
156
|
+
# aggregate — verified before this comment was written, not
|
|
157
|
+
# assumed.
|
|
148
158
|
#
|
|
149
|
-
# RECURSES into what it finds, so a chain
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
# than tracking visited (type, id) pairs for a cycle nothing here
|
|
155
|
-
# declares.
|
|
159
|
+
# RECURSES into what it finds, so a chain deeper than one hop
|
|
160
|
+
# still resolves in one pass. Depth-bounded rather than cycle-
|
|
161
|
+
# detected — nothing in this corpus dots more than two hops on a
|
|
162
|
+
# fresh argument, and a bound is simpler than tracking visited
|
|
163
|
+
# (type, id) pairs for a cycle nothing here declares.
|
|
156
164
|
DEREFERENCE_DEPTH = 4
|
|
157
165
|
private_constant :DEREFERENCE_DEPTH
|
|
158
166
|
|
|
@@ -69,6 +69,20 @@ module Hecks
|
|
|
69
69
|
@command = command
|
|
70
70
|
@owner_fields = aggregate.attributes.to_set(&:name)
|
|
71
71
|
@owner_fields << aggregate.lifecycle.field.to_sym if aggregate.lifecycle
|
|
72
|
+
# `projects` FIELDS (S12, ADR 0025) ARE OWNER STATE TOO — a
|
|
73
|
+
# `given`/`ensures` reading one (e.g. `customer_status ==
|
|
74
|
+
# "active"`) is reading this record's own stored field, same
|
|
75
|
+
# as any attribute, even though nothing here WRITES it via a
|
|
76
|
+
# declared mutation (`CommandInterpreter#seed_projected_fields`
|
|
77
|
+
# populates it outside this analysis entirely). Left out of
|
|
78
|
+
# `known_writes` deliberately: `add_preservation_reads` then
|
|
79
|
+
# correctly treats it as a prior-state read that must survive
|
|
80
|
+
# a partial mutation, which is exactly right — a projected
|
|
81
|
+
# field's freshness comes from the interpreter reseeding it on
|
|
82
|
+
# save, not from anything a caller-supplied write set carries.
|
|
83
|
+
if aggregate.respond_to?(:projected_fields)
|
|
84
|
+
aggregate.projected_fields.each { |field| @owner_fields << field.name }
|
|
85
|
+
end
|
|
72
86
|
@payload_fields = command.attributes.to_set(&:name)
|
|
73
87
|
@state_reads = Set.new
|
|
74
88
|
@payload_reads = Set.new
|
|
@@ -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
|