hecks 1.0.2 → 1.1.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/claude_code.rb +3 -3
- data/lib/hecks/adapters/driven/d1.rb +48 -28
- data/lib/hecks/adapters/driven/folder.rb +10 -3
- data/lib/hecks/adapters/driven/google_authentication.rb +2 -4
- data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
- data/lib/hecks/adapters/driven/heki.rb +3 -5
- data/lib/hecks/adapters/driven/in_memory_ordering.rb +4 -1
- data/lib/hecks/adapters/driven/lambda.rb +2 -2
- data/lib/hecks/adapters/driven/local_storage.adapter +3 -0
- data/lib/hecks/adapters/driven/local_storage.rb +120 -0
- data/lib/hecks/adapters/driven/memory.rb +48 -3
- data/lib/hecks/adapters/driven/postgres/codec.rb +3 -1
- data/lib/hecks/adapters/driven/postgres/outbox.rb +97 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +3 -2
- data/lib/hecks/adapters/driven/postgres.rb +25 -14
- data/lib/hecks/adapters/driven/prism.rb +11 -0
- data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
- data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
- data/lib/hecks/adapters/driven/sqlite/projection.rb +17 -1
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
- data/lib/hecks/adapters/driven/sqlite.rb +87 -8
- data/lib/hecks/adapters/driven.rb +4 -0
- data/lib/hecks/adapters.rb +4 -0
- data/lib/hecks/behaviors/dsl.rb +7 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/behaviors/rspec.rb +2 -0
- data/lib/hecks/behaviors/runner.rb +4 -0
- data/lib/hecks/bluebook/aggregate.rb +6 -5
- data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
- data/lib/hecks/bluebook/assembly/build.rb +6 -3
- data/lib/hecks/bluebook/assembly/contract.rb +5 -1
- data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
- data/lib/hecks/bluebook/assembly/marks.rb +2 -1
- data/lib/hecks/bluebook/assembly.rb +1 -3
- data/lib/hecks/bluebook/attribute.rb +6 -0
- data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
- data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
- data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
- data/lib/hecks/bluebook/command.rb +6 -5
- data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +19 -472
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
- data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
- data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
- data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
- data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
- data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
- data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
- data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +11 -3
- data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
- data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
- data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
- data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
- data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
- data/lib/hecks/bluebook/entity.rb +5 -3
- data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
- data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
- data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
- data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
- data/lib/hecks/bluebook/expression/projection.json +12 -0
- data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
- data/lib/hecks/bluebook/expression/resolver.rb +83 -11
- data/lib/hecks/bluebook/expression.rb +1 -0
- data/lib/hecks/bluebook/hexagon.rb +10 -0
- data/lib/hecks/bluebook/lifecycle.rb +9 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
- data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
- data/lib/hecks/bluebook/meta_validator.rb +5 -5
- data/lib/hecks/bluebook/model_check.rb +119 -82
- data/lib/hecks/bluebook/pattern_subset.rb +9 -0
- data/lib/hecks/bluebook/policy.rb +2 -1
- data/lib/hecks/bluebook/process_manager.rb +6 -0
- data/lib/hecks/bluebook/project_register.rb +45 -25
- data/lib/hecks/bluebook/query.rb +4 -0
- data/lib/hecks/bluebook/read_model.rb +2 -2
- data/lib/hecks/bluebook/smoke_test.rb +5 -0
- data/lib/hecks/bluebook/synthesizer.rb +2 -1
- data/lib/hecks/bluebook/translation.rb +3 -0
- data/lib/hecks/bluebook/value_object.rb +2 -2
- data/lib/hecks/codemod.rb +13 -0
- data/lib/hecks/doc/reference.rb +17 -11
- data/lib/hecks/facade/cli_runner.rb +2 -2
- data/lib/hecks/facade/command_request.rb +11 -11
- data/lib/hecks/facade/handle.rb +3 -3
- data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
- data/lib/hecks/facade.rb +4 -0
- data/lib/hecks/forms/app.rb +10 -6
- data/lib/hecks/forms/command_form_renderer.rb +3 -3
- data/lib/hecks/forms/field_renderer.rb +14 -9
- data/lib/hecks/forms/field_shape.rb +7 -2
- data/lib/hecks/forms/html.rb +5 -2
- data/lib/hecks/forms/index_renderer.rb +1 -1
- data/lib/hecks/forms/page.rb +7 -3
- data/lib/hecks/forms/query_form_renderer.rb +3 -2
- data/lib/hecks/forms/record_renderer.rb +22 -16
- data/lib/hecks/forms.rb +4 -0
- data/lib/hecks/fqn.rb +21 -9
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
- data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
- data/lib/hecks/fuzzing/properties/guards.rb +251 -0
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
- data/lib/hecks/fuzzing/properties/querying.rb +206 -0
- data/lib/hecks/fuzzing/properties.rb +41 -1065
- data/lib/hecks/fuzzing/replay.rb +60 -16
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
- data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
- data/lib/hecks/fuzzing/value_generator.rb +2 -2
- data/lib/hecks/grammar/evolve.rb +34 -10
- data/lib/hecks/grammar/expression_operators.json +124 -0
- data/lib/hecks/grammar.rb +15 -5
- data/lib/hecks/ir.rb +9 -0
- data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
- data/lib/hecks/language/bluebook/command.bluebook +2 -1
- data/lib/hecks/language/bluebook/entity.bluebook +2 -1
- data/lib/hecks/language/bluebook/projection.bluebook +2 -1
- data/lib/hecks/language/bluebook/query.bluebook +4 -2
- data/lib/hecks/language/bluebook/shape.bluebook +2 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
- data/lib/hecks/literal.rb +18 -8
- data/lib/hecks/naming.rb +6 -3
- data/lib/hecks/ports/access_control.rb +1 -1
- data/lib/hecks/ports/agent.rb +5 -3
- data/lib/hecks/ports/authentication.rb +3 -2
- data/lib/hecks/ports/authorization.rb +1 -1
- data/lib/hecks/ports/clock.rb +1 -1
- data/lib/hecks/ports/extraction.rb +5 -1
- data/lib/hecks/ports/identity_assignment.rb +1 -1
- data/lib/hecks/ports/identity_generation.rb +1 -1
- data/lib/hecks/ports/identity_resolution.rb +1 -1
- data/lib/hecks/ports/loading.rb +4 -1
- data/lib/hecks/ports/persistence/append_only.rb +40 -4
- data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
- data/lib/hecks/ports/persistence/plugin.rb +3 -0
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
- data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +23 -13
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
- data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
- data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
- data/lib/hecks/ports/persistence.rb +7 -3
- data/lib/hecks/ports/projection.rb +13 -5
- data/lib/hecks/ports/query/in_memory.rb +8 -2
- data/lib/hecks/ports/query.rb +1 -3
- data/lib/hecks/ports.rb +2 -0
- data/lib/hecks/projections/diagrams.rb +11 -3
- data/lib/hecks/projections/ir.rb +2 -0
- data/lib/hecks/projections/model/deviations.rb +2 -1
- data/lib/hecks/projections/model.rb +15 -10
- data/lib/hecks/projections/vocabulary.rb +1 -3
- data/lib/hecks/projector/cli_projector.rb +49 -29
- data/lib/hecks/projector/docs_projector.rb +21 -12
- data/lib/hecks/projector/exporter.rb +10 -1
- data/lib/hecks/projector/narrate_projector.rb +83 -25
- data/lib/hecks/projector.rb +2 -2
- data/lib/hecks/query_ir.rb +60 -36
- data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
- data/lib/hecks/query_specification/common/comparators.rb +4 -0
- data/lib/hecks/query_specification/common/comparison.rb +8 -1
- data/lib/hecks/query_specification/common/dsl.rb +5 -0
- data/lib/hecks/query_specification/common/null_policy.rb +6 -2
- data/lib/hecks/query_specification/common/options.rb +17 -5
- data/lib/hecks/query_specification/field_path.rb +4 -4
- data/lib/hecks/query_specification/read_model/specification.rb +5 -2
- data/lib/hecks/router/namespace_installer.rb +22 -10
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
- data/lib/hecks/runtime/command_interpreter.rb +104 -65
- data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
- data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
- data/lib/hecks/runtime/dependency_planning.rb +33 -21
- data/lib/hecks/runtime/dispatcher.rb +41 -7
- data/lib/hecks/runtime/entity_element.rb +25 -6
- data/lib/hecks/runtime/entity_interpreter.rb +28 -15
- data/lib/hecks/runtime/errors.rb +28 -2
- data/lib/hecks/runtime/instance.rb +9 -3
- data/lib/hecks/runtime/interpreting.rb +29 -2
- data/lib/hecks/runtime/loader.rb +21 -0
- data/lib/hecks/runtime/outbox.rb +365 -0
- data/lib/hecks/runtime/policy_interpreter.rb +32 -17
- data/lib/hecks/runtime/query_interpreter.rb +36 -16
- data/lib/hecks/runtime/reaction_invocation.rb +35 -22
- data/lib/hecks/runtime/read_model_interpreter.rb +26 -3
- data/lib/hecks/runtime/reference_hop.rb +1 -1
- data/lib/hecks/runtime/refusal_wording.rb +55 -49
- data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
- data/lib/hecks/runtime/registry/verification.rb +39 -2
- data/lib/hecks/runtime/registry.rb +21 -11
- data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
- data/lib/hecks/runtime/routing.rb +52 -39
- data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
- data/lib/hecks/runtime/saga_interpreter.rb +119 -90
- data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
- data/lib/hecks/runtime/tenant_scope.rb +7 -0
- data/lib/hecks/runtime/value/coercion.rb +207 -48
- data/lib/hecks/runtime/value.rb +1 -1
- data/lib/hecks/runtime.rb +7 -2
- data/lib/hecks/storehouse.rb +15 -8
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +1 -0
- data/lib/hecks.rb +17 -13
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
- metadata +14 -2
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "event"
|
|
5
|
+
require_relative "../naming"
|
|
6
|
+
|
|
7
|
+
module Hecks
|
|
8
|
+
module Runtime
|
|
9
|
+
# THE TRANSACTIONAL OUTBOX — the durable hand-off between "a command
|
|
10
|
+
# committed" and "everything that was owed because it committed":
|
|
11
|
+
# the policies that react to its events, the process managers that
|
|
12
|
+
# advance on them, and (through a policy whose trigger is an
|
|
13
|
+
# outbound port operation) the external effects those reactions
|
|
14
|
+
# cause. `future-features.md` item 8, built.
|
|
15
|
+
#
|
|
16
|
+
# THE SHAPE. One row per (event, consumer). A consumer is a named
|
|
17
|
+
# policy or process manager that would react to the event — resolved
|
|
18
|
+
# at ENQUEUE time from the registry (`Fanout`), so the outbox records
|
|
19
|
+
# WHO was owed what, not just that an event happened. Rows move
|
|
20
|
+
# `pending → claimed → delivered | failed`:
|
|
21
|
+
#
|
|
22
|
+
# pending written in the SAME adapter transaction as the
|
|
23
|
+
# aggregate save (`Interpreting#run_dispatch_order`
|
|
24
|
+
# wraps `save` + `emit` + enqueue in `repository.
|
|
25
|
+
# transaction`), so a row exists iff the state change
|
|
26
|
+
# it reacts to committed — never one without the other.
|
|
27
|
+
# claimed the relay is about to run this consumer. Set BEFORE
|
|
28
|
+
# the reaction (a policy's `reenter`, a saga leg's
|
|
29
|
+
# dispatch, an adapter call) runs.
|
|
30
|
+
# delivered the consumer ran to completion — including the
|
|
31
|
+
# "ran and was refused" case, which is a delivered
|
|
32
|
+
# outcome the reaction log already records; the outbox
|
|
33
|
+
# tracks delivery, not the domain's answer.
|
|
34
|
+
# failed the consumer raised a defect (non-refusal error).
|
|
35
|
+
#
|
|
36
|
+
# DELIVERY IS INLINE BY DEFAULT — the dispatcher drains the rows it
|
|
37
|
+
# just wrote, in the same call, in the order C10.2 fixes (per event
|
|
38
|
+
# in `emits` order: that event's policy rows, then its saga rows —
|
|
39
|
+
# the emitting domain's own policies before other domains'). Nothing about the
|
|
40
|
+
# happy path is deferred or asynchronous; a caller still sees every
|
|
41
|
+
# reaction settled when `dispatch` returns. What changes is the
|
|
42
|
+
# crash window: a process that dies between commit and reaction
|
|
43
|
+
# used to lose the reaction silently. Now the row survives, and
|
|
44
|
+
# `Relay#redrive!` — run at boot by `Loader.run_boot_gates!` —
|
|
45
|
+
# finds it.
|
|
46
|
+
#
|
|
47
|
+
# WHAT REDRIVE DOES, AND DELIBERATELY DOESN'T. A `pending` row is
|
|
48
|
+
# REDRIVEN: its consumer provably never started (claiming is the
|
|
49
|
+
# first thing delivery does), so running it now is exactly-once by
|
|
50
|
+
# construction. A `claimed` row is NOT auto-redriven: the consumer
|
|
51
|
+
# started and the crash hid its outcome — the same reasoning
|
|
52
|
+
# `saga_pending_dispatch.rb` gives (a stalled transfer is a better
|
|
53
|
+
# defect than a double-credited one). It is surfaced loudly
|
|
54
|
+
# (`warn`, and a `stalled: true` entry in `Relay#log`) and left for
|
|
55
|
+
# `Relay#redrive!(claimed: true)` — an explicit operator decision,
|
|
56
|
+
# never a boot-time default. `delivery_id` (event uid + consumer) is
|
|
57
|
+
# UNIQUE per store, so a re-enqueue of the same fact to the same
|
|
58
|
+
# consumer is a no-op rather than a second row.
|
|
59
|
+
#
|
|
60
|
+
# WHICH ADAPTERS. Memory (in-process rows — visible to specs,
|
|
61
|
+
# gone with the process, exactly like everything else Memory holds),
|
|
62
|
+
# Sqlite and Postgres (a `hecks_outbox` table in the aggregate's own
|
|
63
|
+
# database — the only way the enqueue can share the save's
|
|
64
|
+
# transaction). An adapter without the contract (`outbox_enqueue`/
|
|
65
|
+
# `outbox_claim`/`outbox_settle`/`outbox_rows`) gets today's
|
|
66
|
+
# behaviour unchanged — reactions run directly, nothing durable —
|
|
67
|
+
# and `Registry::Verification#warn_undurable_outbox!` says so at
|
|
68
|
+
# boot when the domain declares anything that would have needed it.
|
|
69
|
+
module Outbox
|
|
70
|
+
STATUSES = %w[pending claimed delivered failed].freeze
|
|
71
|
+
|
|
72
|
+
Row = Struct.new(:id, :delivery_id, :event_uid, :aggregate, :domain, :kind, :consumer, :event,
|
|
73
|
+
:status, :attempts, :error, keyword_init: true) do
|
|
74
|
+
def pending? = status == "pending"
|
|
75
|
+
def claimed? = status == "claimed"
|
|
76
|
+
def delivered? = status == "delivered"
|
|
77
|
+
def failed? = status == "failed"
|
|
78
|
+
|
|
79
|
+
# Wire-shaped — what an adapter persists. `event` is the event's
|
|
80
|
+
# own `to_h` plus correlation; `Row.event_from` reverses it.
|
|
81
|
+
def to_h
|
|
82
|
+
{ id: id, delivery_id: delivery_id, event_uid: event_uid, aggregate: aggregate, domain: domain,
|
|
83
|
+
kind: kind, consumer: consumer, event: event, status: status, attempts: attempts, error: error }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def to_s = "#{consumer} ← #{event[:name]}(#{event[:aggregate]}##{event[:id]}) [#{status}]"
|
|
87
|
+
def inspect = "#<Outbox::Row #{self}>"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
module_function
|
|
91
|
+
|
|
92
|
+
def serialize_event(event)
|
|
93
|
+
event.to_h.merge(correlation: event.correlation)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# THE EMITTING DOMAIN'S OWN BLUEBOOK FIRST, then the rest in load
|
|
97
|
+
# order (C10.2) — the one policy ordering both `PolicyInterpreter#
|
|
98
|
+
# policies_for` and `Fanout.policies` read.
|
|
99
|
+
def bluebooks_home_first(registry, domain)
|
|
100
|
+
home, others = registry.bluebooks.each_value.partition { |bluebook| bluebook.name == domain }
|
|
101
|
+
home + others
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def event_from(hash)
|
|
105
|
+
hash = hash.transform_keys(&:to_sym)
|
|
106
|
+
Event.new(
|
|
107
|
+
name: hash[:name],
|
|
108
|
+
aggregate: hash[:aggregate],
|
|
109
|
+
id: hash[:id],
|
|
110
|
+
payload: deep_symbolize(hash[:payload] || {}),
|
|
111
|
+
occurred_at: hash[:occurred_at],
|
|
112
|
+
correlation: hash[:correlation]
|
|
113
|
+
).emit!
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def deep_symbolize(value)
|
|
117
|
+
case value
|
|
118
|
+
when Hash then value.to_h { |k, v| [k.to_sym, deep_symbolize(v)] }
|
|
119
|
+
when Array then value.map { |element| deep_symbolize(element) }
|
|
120
|
+
else value
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# WHO IS OWED WHAT — the same selection `PolicyInterpreter#policies_for`
|
|
125
|
+
# and `SagaInterpreter#advance` make at delivery time, made once at
|
|
126
|
+
# enqueue time so the row names its consumer. Policy rows first,
|
|
127
|
+
# then saga rows, event order preserved within each: exactly the
|
|
128
|
+
# order `Dispatcher#dispatch` always ran them in.
|
|
129
|
+
module Fanout
|
|
130
|
+
module_function
|
|
131
|
+
|
|
132
|
+
# ONE UID PER EVENT for this enqueue — the Event struct is
|
|
133
|
+
# frozen after `emit!`, so the uid lives on the rows rather than
|
|
134
|
+
# on it (and stays off `Event#to_h`, whose shape the golden and
|
|
135
|
+
# parity specs pin). Policy and saga rows for the same event
|
|
136
|
+
# share it, which is what makes `delivery_id` mean "this fact,
|
|
137
|
+
# this consumer".
|
|
138
|
+
# ROW ORDER IS DELIVERY ORDER (C10.2): per event, in `emits`
|
|
139
|
+
# order — that event's policy rows, then its saga rows.
|
|
140
|
+
def rows_for(registry, events, domain)
|
|
141
|
+
uids = events.to_h { |event| [event, SecureRandom.uuid] }
|
|
142
|
+
events.flat_map do |event|
|
|
143
|
+
policies(registry, event, domain, uids[event]) + sagas(registry, event, domain, uids[event])
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def policies(registry, event, domain, uid)
|
|
148
|
+
emitting = Naming.demodulise(event.aggregate)
|
|
149
|
+
Outbox.bluebooks_home_first(registry, domain).flat_map do |bluebook|
|
|
150
|
+
bluebook.policies.filter_map do |policy|
|
|
151
|
+
next unless policy.event_name == event.name
|
|
152
|
+
next unless policy.event_qualifier.nil? || policy.event_qualifier == emitting
|
|
153
|
+
|
|
154
|
+
consumer = "policy:#{bluebook.name}::#{policy.name}"
|
|
155
|
+
Row.new(delivery_id: "#{uid}/#{consumer}", event_uid: uid, domain: domain,
|
|
156
|
+
kind: kind_for(registry, policy, bluebook.name), consumer: consumer,
|
|
157
|
+
event: Outbox.serialize_event(event), status: "pending", attempts: 0)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def sagas(registry, event, domain, uid)
|
|
163
|
+
bluebook = registry.bluebook(domain)
|
|
164
|
+
return [] unless bluebook
|
|
165
|
+
|
|
166
|
+
bluebook.process_managers.select { |process_manager| listens?(process_manager, event) }.map do |process_manager|
|
|
167
|
+
consumer = "saga:#{bluebook.name}::#{process_manager.name}"
|
|
168
|
+
Row.new(delivery_id: "#{uid}/#{consumer}", event_uid: uid, domain: domain, kind: "reaction",
|
|
169
|
+
consumer: consumer, event: Outbox.serialize_event(event), status: "pending", attempts: 0)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def listens?(process_manager, event)
|
|
174
|
+
process_manager.starts_on == event.name || process_manager.ends_on == event.name ||
|
|
175
|
+
!process_manager.handler_for(event.name).nil?
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# An "effect" is a reaction whose trigger is an outbound port
|
|
179
|
+
# operation — the row is the durable record that an external
|
|
180
|
+
# call was owed, claimed right before the adapter is asked and
|
|
181
|
+
# settled right after. Everything else is a plain "reaction".
|
|
182
|
+
def kind_for(registry, policy, home_domain)
|
|
183
|
+
target = "#{policy.target_domain || home_domain}::#{policy.trigger_command}"
|
|
184
|
+
parsed = Naming.split_verb(target)
|
|
185
|
+
return "reaction" unless parsed
|
|
186
|
+
|
|
187
|
+
target_domain, aggregate_name, path = parsed
|
|
188
|
+
head, rest = path.to_s.split(".", 2)
|
|
189
|
+
return "reaction" unless rest
|
|
190
|
+
|
|
191
|
+
aggregate = registry.bluebook(target_domain)&.aggregate(aggregate_name)
|
|
192
|
+
port = aggregate&.port(head)
|
|
193
|
+
port&.operation(rest)&.outbound? ? "effect" : "reaction"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# THE RELAY — one per `Dispatcher`. Enqueues into a repository's
|
|
198
|
+
# store, drains rows inline, and redrives what a previous process
|
|
199
|
+
# left behind.
|
|
200
|
+
class Relay
|
|
201
|
+
attr_reader :registry, :log
|
|
202
|
+
|
|
203
|
+
# NOT `saga_log`/`reaction_log` — those are ported byte-for-byte
|
|
204
|
+
# by the Rust kernel (`spec/rust_conformance_spec.rb`); this is
|
|
205
|
+
# an additive, Ruby-only log, the same rule `saga_dispatch_log`
|
|
206
|
+
# and `policy_dispatch_log` already follow.
|
|
207
|
+
def initialize(registry)
|
|
208
|
+
@registry = registry
|
|
209
|
+
@log = []
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# A Dispatcher hands over the interpreters a consumer runs
|
|
213
|
+
# through (`Dispatcher#initialize`). Until then this relay can
|
|
214
|
+
# enqueue (that needs only the registry) but not deliver — and
|
|
215
|
+
# nothing can dispatch without a dispatcher, so nothing asks it
|
|
216
|
+
# to. The registry holds ONE relay for its lifetime; a second
|
|
217
|
+
# dispatcher fronting the same registry re-attaches, which is
|
|
218
|
+
# fine because both dispatchers share every log and store.
|
|
219
|
+
def attach(policies:, sagas:)
|
|
220
|
+
@policies = policies
|
|
221
|
+
@sagas = sagas
|
|
222
|
+
self
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def attached? = !@policies.nil?
|
|
226
|
+
|
|
227
|
+
# Called INSIDE the save transaction by `Interpreting` for the
|
|
228
|
+
# command/entity paths, and outside one by `Dispatcher` for port
|
|
229
|
+
# operations (which save nothing, so there is no transaction to
|
|
230
|
+
# share). Returns the rows as stored (ids assigned), or nil when
|
|
231
|
+
# the repository has no outbox — the dispatcher then reacts
|
|
232
|
+
# directly, exactly as before.
|
|
233
|
+
def enqueue(repository, events, domain)
|
|
234
|
+
return nil unless repository.outbox?
|
|
235
|
+
return [] if events.empty?
|
|
236
|
+
|
|
237
|
+
rows = Fanout.rows_for(@registry, events, domain)
|
|
238
|
+
rows.each { |row| row.aggregate = repository.aggregate.storage_name }
|
|
239
|
+
repository.outbox_enqueue(rows)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Drain the rows a dispatch just committed. `rows` nil means "no
|
|
243
|
+
# outbox here" — react directly, the pre-outbox path.
|
|
244
|
+
def deliver(rows, events, domain, repository)
|
|
245
|
+
if rows.nil?
|
|
246
|
+
# PER EVENT, in `emits` order — its policies, then its sagas
|
|
247
|
+
# (C10.2, docs/semantics/bluebook-semantics.md); the same
|
|
248
|
+
# order `Fanout.rows_for` lays the outbox rows in.
|
|
249
|
+
events.each do |event|
|
|
250
|
+
@policies.react(event, domain)
|
|
251
|
+
@sagas.advance(event, domain)
|
|
252
|
+
end
|
|
253
|
+
return
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
rows.each { |row| deliver_row(row, repository) }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# One row: claim, run its consumer, settle. A claim that fails
|
|
260
|
+
# means another relay (or this one, re-entrantly) already has it.
|
|
261
|
+
def deliver_row(row, repository)
|
|
262
|
+
return false unless repository.outbox_claim(row.id)
|
|
263
|
+
|
|
264
|
+
row.status = "claimed"
|
|
265
|
+
begin
|
|
266
|
+
run_consumer(row)
|
|
267
|
+
repository.outbox_settle(row.id, status: "delivered")
|
|
268
|
+
row.status = "delivered"
|
|
269
|
+
true
|
|
270
|
+
rescue StandardError => e
|
|
271
|
+
# A DOMAIN_REFUSAL never reaches here — PolicyInterpreter and
|
|
272
|
+
# SagaInterpreter both rescue it as a recorded, undelivered
|
|
273
|
+
# reaction. Anything that does reach here is a defect in the
|
|
274
|
+
# relay's own path (a consumer that no longer exists, an
|
|
275
|
+
# adapter that raised outside the interpreters' own rescue).
|
|
276
|
+
repository.outbox_settle(row.id, status: "failed", error: "#{e.class}: #{e.message}")
|
|
277
|
+
row.status = "failed"
|
|
278
|
+
row.error = "#{e.class}: #{e.message}"
|
|
279
|
+
@log << { outbox: row.delivery_id, consumer: row.consumer, delivered: false, defect: true,
|
|
280
|
+
reason: row.error }
|
|
281
|
+
false
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Every row in every bound store, newest last. `status:` narrows.
|
|
286
|
+
def rows(status: nil)
|
|
287
|
+
stores.flat_map { |repository| repository.outbox_rows(status: status) }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# BOOT-TIME RECONCILIATION. Redrives `pending` rows (never
|
|
291
|
+
# claimed — safe by construction); surfaces `claimed` rows and
|
|
292
|
+
# redrives them ONLY when told to (`claimed: true`).
|
|
293
|
+
def redrive!(claimed: false)
|
|
294
|
+
redriven = []
|
|
295
|
+
stores.each do |repository|
|
|
296
|
+
repository.outbox_rows(status: "pending").each do |row|
|
|
297
|
+
redriven << row if deliver_row(row, repository)
|
|
298
|
+
end
|
|
299
|
+
repository.outbox_rows(status: "claimed").each do |row|
|
|
300
|
+
if claimed
|
|
301
|
+
repository.outbox_settle(row.id, status: "pending")
|
|
302
|
+
row.status = "pending"
|
|
303
|
+
redriven << row if deliver_row(row, repository)
|
|
304
|
+
else
|
|
305
|
+
warn_stalled(row)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
redriven
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
private
|
|
313
|
+
|
|
314
|
+
def run_consumer(row)
|
|
315
|
+
raise WiringError, "outbox relay has no dispatcher attached — nothing can run #{row.consumer}" unless attached?
|
|
316
|
+
|
|
317
|
+
event = Outbox.event_from(row.event)
|
|
318
|
+
kind, fqn = row.consumer.split(":", 2)
|
|
319
|
+
home, name = fqn.split("::", 2)
|
|
320
|
+
case kind
|
|
321
|
+
when "policy"
|
|
322
|
+
policy = @registry.bluebook(home)&.policies&.find { |candidate| candidate.name == name } ||
|
|
323
|
+
raise(WiringError, "outbox row #{row.delivery_id} names policy #{fqn}, which no bluebook declares")
|
|
324
|
+
@policies.react(event, row.domain, only: [policy, home])
|
|
325
|
+
when "saga"
|
|
326
|
+
process_manager = @registry.bluebook(home)&.process_managers&.find { |candidate| candidate.name == name } ||
|
|
327
|
+
raise(WiringError,
|
|
328
|
+
"outbox row #{row.delivery_id} names process_manager #{fqn}, which no bluebook declares")
|
|
329
|
+
@sagas.advance(event, row.domain, only: process_manager)
|
|
330
|
+
else
|
|
331
|
+
raise WiringError, "outbox row #{row.delivery_id} has an unknown consumer kind #{kind.inspect}"
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def warn_stalled(row)
|
|
336
|
+
warn "[hecks] outbox row #{row.delivery_id} (#{row.consumer} on #{row.event[:name]} for " \
|
|
337
|
+
"#{row.event[:aggregate]}##{row.event[:id]}) was claimed before the last crash/restart and never " \
|
|
338
|
+
"settled — its #{row.kind} may or may not have actually run. hecks does not auto-redrive a claimed " \
|
|
339
|
+
"row (the outcome is unknown, and redelivering it could double the effect); inspect it and " \
|
|
340
|
+
"redrive by hand with `runtime.outbox.redrive!(claimed: true)` once you know it is safe."
|
|
341
|
+
@log << { outbox: row.delivery_id, consumer: row.consumer, kind: row.kind, stalled: true,
|
|
342
|
+
event: row.event[:name], aggregate: row.event[:aggregate], id: row.event[:id] }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Every repository the registry can resolve, one per (domain,
|
|
346
|
+
# aggregate), keeping only those with an outbox. Bluebooks, not
|
|
347
|
+
# hecksagons: a domain with no hecksagon at all is bound to the
|
|
348
|
+
# default adapter (Memory), which has one. An aggregate a
|
|
349
|
+
# hecksagon deliberately left unbound raises WiringError from
|
|
350
|
+
# `repository` and is skipped — the same "forgotten decision"
|
|
351
|
+
# rule dispatch itself applies, not softened here.
|
|
352
|
+
def stores
|
|
353
|
+
@registry.bluebooks.each_value.flat_map do |bluebook|
|
|
354
|
+
bluebook.aggregates.filter_map do |aggregate|
|
|
355
|
+
repository = @registry.repository(bluebook.name, aggregate)
|
|
356
|
+
repository if repository.outbox?
|
|
357
|
+
rescue WiringError
|
|
358
|
+
nil
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
@@ -8,6 +8,11 @@ require_relative "../bluebook/expression/evaluator"
|
|
|
8
8
|
|
|
9
9
|
module Hecks
|
|
10
10
|
module Runtime
|
|
11
|
+
# Fires the declared `policy` reactions triggered by one just-emitted
|
|
12
|
+
# event: scans every loaded bluebook (a policy commonly lives in a
|
|
13
|
+
# different domain than the event it reacts to), checks each
|
|
14
|
+
# candidate's `where` guard, and re-enters the dispatcher for each
|
|
15
|
+
# delivery — recording every outcome on the reaction log.
|
|
11
16
|
class PolicyInterpreter
|
|
12
17
|
attr_reader :registry
|
|
13
18
|
|
|
@@ -23,8 +28,13 @@ module Hecks
|
|
|
23
28
|
# (one record per matched row) rather than one record ; `Array(...)`
|
|
24
29
|
# is wrong here (it would explode a plain record Hash into its own
|
|
25
30
|
# key/value pairs), so the two shapes are told apart explicitly.
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
# `only:` — ONE `[policy, home_domain]` pair, the outbox relay's
|
|
32
|
+
# way of running exactly the consumer a row names (`Runtime::
|
|
33
|
+
# Outbox::Relay#run_consumer`) instead of every policy that
|
|
34
|
+
# matches the event. Selection is otherwise identical.
|
|
35
|
+
def react(event, domain, only: nil)
|
|
36
|
+
selected = only ? [only] : policies_for(event, domain)
|
|
37
|
+
selected.each do |policy, home_domain|
|
|
28
38
|
result = deliver(policy, event, home_domain)
|
|
29
39
|
next if result.nil?
|
|
30
40
|
|
|
@@ -52,10 +62,15 @@ module Hecks
|
|
|
52
62
|
# OWN domain (not the emitting one) when a trigger or for_each route
|
|
53
63
|
# is bare, and that fallback has to travel with each match now that
|
|
54
64
|
# a single event can surface policies from several different homes.
|
|
55
|
-
|
|
65
|
+
#
|
|
66
|
+
# THE EMITTING DOMAIN'S OWN POLICIES FIRST, in declaration order,
|
|
67
|
+
# then other bluebooks' in load order (C10.2) — the same order the
|
|
68
|
+
# outbox lays its rows in (`Outbox::Fanout.policies`) and the Rust
|
|
69
|
+
# kernel runs (`react_policies`: its own table, then cross-domain).
|
|
70
|
+
def policies_for(event, domain)
|
|
56
71
|
emitting = Naming.demodulise(event.aggregate)
|
|
57
72
|
|
|
58
|
-
@registry.
|
|
73
|
+
Outbox.bluebooks_home_first(@registry, domain).flat_map do |bluebook|
|
|
59
74
|
matching = bluebook.policies.select do |policy|
|
|
60
75
|
policy.event_name == event.name &&
|
|
61
76
|
(policy.event_qualifier.nil? || policy.event_qualifier == emitting)
|
|
@@ -78,7 +93,7 @@ module Hecks
|
|
|
78
93
|
def where_holds?(policy, event)
|
|
79
94
|
return true if policy.where.to_s.empty?
|
|
80
95
|
|
|
81
|
-
Bluebook::Expression::Evaluator.
|
|
96
|
+
Bluebook::Expression::Evaluator.call_rule(policy.where_rule, {}, event.payload.transform_keys(&:to_sym))
|
|
82
97
|
end
|
|
83
98
|
|
|
84
99
|
def deliver(policy, event, domain)
|
|
@@ -101,11 +116,11 @@ module Hecks
|
|
|
101
116
|
args = trigger_args(policy, event)
|
|
102
117
|
@door.reenter(target, **reaction_invocation(target, args, policy, event))
|
|
103
118
|
record.merge(delivered: true)
|
|
104
|
-
rescue *DOMAIN_REFUSALS =>
|
|
119
|
+
rescue *DOMAIN_REFUSALS => e
|
|
105
120
|
# The target refused — a fact about the domain, recorded and not
|
|
106
121
|
# fatal to the command that emitted the event.
|
|
107
|
-
record.merge(delivered: false, reason:
|
|
108
|
-
rescue StandardError =>
|
|
122
|
+
record.merge(delivered: false, reason: e.message)
|
|
123
|
+
rescue StandardError => e
|
|
109
124
|
# A DEFECT, not a refusal — a NoMethodError in an interpreter, a
|
|
110
125
|
# NameError from a missing constant, a TypeError from a bad
|
|
111
126
|
# assumption : exactly the class of thing DOMAIN_REFUSALS
|
|
@@ -133,8 +148,8 @@ module Hecks
|
|
|
133
148
|
# is never silent, and left exactly where it happened for a human
|
|
134
149
|
# to find — never re-raised, and never swallowed either.
|
|
135
150
|
warn "[hecks] defect in reaction — policy #{policy.name} on #{event.name} " \
|
|
136
|
-
"firing #{target}: #{
|
|
137
|
-
record.merge(delivered: false, reason:
|
|
151
|
+
"firing #{target}: #{e.class}: #{e.message}"
|
|
152
|
+
record.merge(delivered: false, reason: e.message, defect: true, error_class: e.class.name)
|
|
138
153
|
end
|
|
139
154
|
|
|
140
155
|
# THE FAN-OUT — `policy.for_each` names a query ; this runs it
|
|
@@ -169,12 +184,12 @@ module Hecks
|
|
|
169
184
|
Array(rows).map do |row|
|
|
170
185
|
deliver_for_each_row(target, record, trigger_args(policy, event, reference_key => row[:id]), row, policy, event)
|
|
171
186
|
end
|
|
172
|
-
rescue *DOMAIN_REFUSALS =>
|
|
173
|
-
record.merge(delivered: false, reason:
|
|
174
|
-
rescue StandardError =>
|
|
187
|
+
rescue *DOMAIN_REFUSALS => e
|
|
188
|
+
record.merge(delivered: false, reason: e.message)
|
|
189
|
+
rescue StandardError => e
|
|
175
190
|
warn "[hecks] defect in reaction — policy #{policy.name} on #{event.name} " \
|
|
176
|
-
"resolving for_each #{policy.for_each}: #{
|
|
177
|
-
record.merge(delivered: false, reason:
|
|
191
|
+
"resolving for_each #{policy.for_each}: #{e.class}: #{e.message}"
|
|
192
|
+
record.merge(delivered: false, reason: e.message, defect: true, error_class: e.class.name)
|
|
178
193
|
end
|
|
179
194
|
|
|
180
195
|
# THE EVENT'S OWN IDENTITY IS A FACT TOO, not only its payload. A
|
|
@@ -332,8 +347,8 @@ module Hecks
|
|
|
332
347
|
# result, or a projection could never name the row it acts on.
|
|
333
348
|
@door.reenter(target, **reaction_invocation(target, args, policy, event))
|
|
334
349
|
row_record.merge(delivered: true)
|
|
335
|
-
rescue *DOMAIN_REFUSALS =>
|
|
336
|
-
row_record.merge(delivered: false, reason:
|
|
350
|
+
rescue *DOMAIN_REFUSALS => e
|
|
351
|
+
row_record.merge(delivered: false, reason: e.message)
|
|
337
352
|
end
|
|
338
353
|
|
|
339
354
|
def reaction_invocation(target, args, policy, event)
|
|
@@ -11,6 +11,14 @@ require_relative "value"
|
|
|
11
11
|
|
|
12
12
|
module Hecks
|
|
13
13
|
module Runtime
|
|
14
|
+
# Answers one declared aggregate or entity query against a
|
|
15
|
+
# repository: prefers a native adapter hook (Ports::Query.execute)
|
|
16
|
+
# when the store can answer directly, falling back to interpreting
|
|
17
|
+
# wheres/order_by/limit/offset over every loaded record itself.
|
|
18
|
+
# #reference_call/#reference_interpret are a SEPARATE, deliberately
|
|
19
|
+
# naive re-implementation of the same evaluation, used only as the
|
|
20
|
+
# fuzzer's oracle to catch divergence between adapters and this
|
|
21
|
+
# interpreter's own native path.
|
|
14
22
|
class QueryInterpreter
|
|
15
23
|
attr_reader :registry
|
|
16
24
|
|
|
@@ -114,11 +122,11 @@ module Hecks
|
|
|
114
122
|
# has no concept of a reference at all — it would just read the
|
|
115
123
|
# raw id straight off the record and compare THAT).
|
|
116
124
|
def reference_interpret(records, declared, args, domain:, shape:)
|
|
117
|
-
matched = records.select
|
|
118
|
-
declared.wheres.all?
|
|
125
|
+
matched = records.select do |r|
|
|
126
|
+
declared.wheres.all? do |w|
|
|
119
127
|
reference_where_holds?(w, r, args, domain: domain, shape: shape)
|
|
120
|
-
|
|
121
|
-
|
|
128
|
+
end
|
|
129
|
+
end
|
|
122
130
|
ordered = ordered(matched, declared.order_by, declared.null_semantics)
|
|
123
131
|
# OFFSET FIRST, THEN LIMIT — same fix, same reasoning, as
|
|
124
132
|
# #interpret's own rows above.
|
|
@@ -157,16 +165,8 @@ module Hecks
|
|
|
157
165
|
|
|
158
166
|
def entity_rows(domain, aggregate, dotted, args)
|
|
159
167
|
entity_name, query_name = Naming.split_dotted(dotted)
|
|
160
|
-
entity = aggregate
|
|
161
|
-
raise(UnknownVerb, RefusalWording.render("UnknownVerb", "entity_unknown",
|
|
162
|
-
aggregate: aggregate.hecks_name, entity: entity_name.inspect))
|
|
163
|
-
declared = entity.query(query_name) ||
|
|
164
|
-
raise(UnknownVerb, RefusalWording.render("UnknownVerb", "entity_query_missing",
|
|
165
|
-
entity: entity_name, query: query_name.inspect))
|
|
168
|
+
entity, declared, list_attr = resolve_entity_query(aggregate, entity_name, query_name)
|
|
166
169
|
declared = TenantScope.apply(declared, args)
|
|
167
|
-
list_attr = aggregate.attributes.find { |a| a.list? && a.type.to_s == entity_name } ||
|
|
168
|
-
raise(UnknownVerb, RefusalWording.render("UnknownVerb", "entity_holds_no_list",
|
|
169
|
-
aggregate: aggregate.hecks_name, entity: entity_name))
|
|
170
170
|
|
|
171
171
|
parent_key = Naming.reference_key(aggregate.hecks_name)
|
|
172
172
|
rows = @registry.repository(domain, aggregate).all.flat_map do |record|
|
|
@@ -186,6 +186,25 @@ module Hecks
|
|
|
186
186
|
declared.limit ? skipped.first(resolve_query_value(declared.limit.value, args).to_i) : skipped
|
|
187
187
|
end
|
|
188
188
|
|
|
189
|
+
# THE THREE DECLARATIONS `entity_rows` NEEDS BEFORE IT CAN READ A
|
|
190
|
+
# SINGLE RECORD — the entity itself, its declared query, and the
|
|
191
|
+
# list attribute that holds it on the aggregate. Extracted from
|
|
192
|
+
# `entity_rows` (pure extraction, same lookups, same order, same
|
|
193
|
+
# UnknownVerb refusals) purely to separate "which declarations does
|
|
194
|
+
# this dotted name resolve to" from the row-computation that follows.
|
|
195
|
+
def resolve_entity_query(aggregate, entity_name, query_name)
|
|
196
|
+
entity = aggregate.entities.find { |piece| piece.hecks_name == entity_name } ||
|
|
197
|
+
raise(UnknownVerb, RefusalWording.render("UnknownVerb", "entity_unknown",
|
|
198
|
+
aggregate: aggregate.hecks_name, entity: entity_name.inspect))
|
|
199
|
+
declared = entity.query(query_name) ||
|
|
200
|
+
raise(UnknownVerb, RefusalWording.render("UnknownVerb", "entity_query_missing",
|
|
201
|
+
entity: entity_name, query: query_name.inspect))
|
|
202
|
+
list_attr = aggregate.attributes.find { |a| a.list? && a.type.to_s == entity_name } ||
|
|
203
|
+
raise(UnknownVerb, RefusalWording.render("UnknownVerb", "entity_holds_no_list",
|
|
204
|
+
aggregate: aggregate.hecks_name, entity: entity_name))
|
|
205
|
+
[entity, declared, list_attr]
|
|
206
|
+
end
|
|
207
|
+
|
|
189
208
|
# FieldPath.dig, not a raw `element[clause.field.to_sym]` — an
|
|
190
209
|
# entity sub-list row is a plain hash merged from stored state
|
|
191
210
|
# (mixed string/symbol keys depending on adapter, per `#cell`'s own
|
|
@@ -259,7 +278,8 @@ module Hecks
|
|
|
259
278
|
declared.attributes.each_with_object(args.dup) do |attribute, normalized|
|
|
260
279
|
next unless normalized.key?(attribute.name)
|
|
261
280
|
|
|
262
|
-
normalized[attribute.name] = Value.for_attribute(aggregate, attribute, normalized[attribute.name]
|
|
281
|
+
normalized[attribute.name] = Value.for_attribute(aggregate, attribute, normalized[attribute.name],
|
|
282
|
+
boundary: false)
|
|
263
283
|
end
|
|
264
284
|
end
|
|
265
285
|
|
|
@@ -277,9 +297,9 @@ module Hecks
|
|
|
277
297
|
def ordered(records, order_by, null_semantics = nil)
|
|
278
298
|
field = order_by&.field
|
|
279
299
|
Ports::Query::Ordering.apply(records, order_by, null_semantics,
|
|
280
|
-
identity: ->(record) { record.id.to_s })
|
|
300
|
+
identity: ->(record) { record.id.to_s }) do |record|
|
|
281
301
|
comparable(QuerySpecification::FieldPath.dig(record, field))
|
|
282
|
-
|
|
302
|
+
end
|
|
283
303
|
end
|
|
284
304
|
end
|
|
285
305
|
end
|
|
@@ -44,35 +44,48 @@ module Hecks
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
with_spec.to_h do |key, source|
|
|
47
|
-
value =
|
|
48
|
-
source
|
|
49
|
-
elsif normalized_bindings.key?(source)
|
|
50
|
-
normalized_bindings.fetch(source)
|
|
51
|
-
else
|
|
52
|
-
visible = normalized_scopes.find { |scope| scope.facts.key?(source) }
|
|
53
|
-
unless visible
|
|
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("; ")
|
|
63
|
-
raise UnknownArgument,
|
|
64
|
-
"#{label}'s with: reads :#{source}, which is not visible in #{names} (visible — #{offered})"
|
|
65
|
-
end
|
|
66
|
-
visible.facts.fetch(source)
|
|
67
|
-
end
|
|
68
|
-
|
|
47
|
+
value = resolved_mapping_value(source, normalized_bindings, normalized_scopes, label)
|
|
69
48
|
[key.to_sym, Value.materialize(value)]
|
|
70
49
|
end
|
|
71
50
|
end
|
|
72
51
|
|
|
52
|
+
# ONE `with:` SOURCE, RESOLVED — pulled out of resolve_mapping
|
|
53
|
+
# because it is a pure function of its own arguments (a literal, a
|
|
54
|
+
# binding, or a name visible in some scope), with no dependency on
|
|
55
|
+
# anything else resolve_mapping's own to_h block is doing.
|
|
56
|
+
def resolved_mapping_value(source, bindings, scopes, label)
|
|
57
|
+
return source unless source.is_a?(Symbol)
|
|
58
|
+
return bindings.fetch(source) if bindings.key?(source)
|
|
59
|
+
|
|
60
|
+
visible = scopes.find { |scope| scope.facts.key?(source) }
|
|
61
|
+
unless visible
|
|
62
|
+
names = scopes.map(&:name).join(" then ")
|
|
63
|
+
# WHAT IS VISIBLE, NAMED. A refusal that only says which name is
|
|
64
|
+
# missing sent a modeler guessing field after field
|
|
65
|
+
# (`number`, `reference`…) at a fan-out row that is addressed
|
|
66
|
+
# by ONE key — `account`, the lowercase aggregate — which
|
|
67
|
+
# nothing else in the domain spells out. The names each scope
|
|
68
|
+
# actually offers are the whole diagnosis; the refusal now
|
|
69
|
+
# lists them, scope by scope.
|
|
70
|
+
offered = scopes.map { |scope| "#{scope.name}: #{scope.facts.keys.sort.join(', ')}" }.join("; ")
|
|
71
|
+
raise UnknownArgument,
|
|
72
|
+
"#{label}'s with: reads :#{source}, which is not visible in #{names} (visible — #{offered})"
|
|
73
|
+
end
|
|
74
|
+
visible.facts.fetch(source)
|
|
75
|
+
end
|
|
76
|
+
private_class_method :resolved_mapping_value
|
|
77
|
+
|
|
73
78
|
# A reaction without an explicit projection retains wholesale legacy
|
|
74
79
|
# forwarding. Choosing `with:` opts into the strict envelope: only target
|
|
75
80
|
# command attributes enter `with:`, while identities become `to:`.
|
|
81
|
+
# `consumed` accumulates across the aggregate-identity and entity-
|
|
82
|
+
# identity steps below, and refuse_unconsumed! at the end reads the
|
|
83
|
+
# FINAL list — an ordering dependency threaded through one shared
|
|
84
|
+
# local. Already leans on private helpers (identity_for,
|
|
85
|
+
# source_receiver_for, command_facts, refuse_unconsumed!) for every
|
|
86
|
+
# piece that IS self-contained; what remains is the sequencing
|
|
87
|
+
# itself, which further splitting would only relocate, not remove.
|
|
88
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
76
89
|
def build(registry:, verb:, projected:, explicit:, passthrough: [], source_receiver: nil)
|
|
77
90
|
args = projected.transform_keys(&:to_sym)
|
|
78
91
|
unless explicit
|