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
|
@@ -10,11 +10,25 @@ require_relative "routing"
|
|
|
10
10
|
require_relative "dependency_planning"
|
|
11
11
|
require_relative "../ports/persistence/execution"
|
|
12
12
|
require_relative "entity_element"
|
|
13
|
+
require_relative "command_interpreter/argument_gate"
|
|
13
14
|
|
|
14
15
|
module Hecks
|
|
15
16
|
module Runtime
|
|
16
17
|
class EntityInterpreter
|
|
17
18
|
include Interpreting
|
|
19
|
+
# THE SAME PAYLOAD GATE aggregate commands and port operations already
|
|
20
|
+
# run — bug audit H1 (docs/audits/2026-08-10-main-bug-audit.md): this
|
|
21
|
+
# class used to run NEITHER refuse_unknown_arguments NOR
|
|
22
|
+
# refuse_absent_arguments, on a comment claiming "an entity inherits
|
|
23
|
+
# its aggregate's own gate." Nothing on the entity dispatch path ever
|
|
24
|
+
# ran one — confirmed live, `LedgerEntry.Reverse` accepted an
|
|
25
|
+
# unrecognized `bogus_arg:` outright, and dispatching it with no
|
|
26
|
+
# `narrative:` silently overwrote the stored narrative with `nil`
|
|
27
|
+
# (`sets :narrative`'s bare self-referential form reads `args[:narrative]`
|
|
28
|
+
# unconditionally). See `step_refuse_unknown_arguments`/
|
|
29
|
+
# `step_refuse_absent_arguments`, below, for how the shared gate is
|
|
30
|
+
# reused rather than reimplemented.
|
|
31
|
+
include CommandInterpreter::ArgumentGate
|
|
18
32
|
|
|
19
33
|
attr_reader :registry
|
|
20
34
|
|
|
@@ -23,13 +37,17 @@ module Hecks
|
|
|
23
37
|
# spec/vocabulary_conformance_spec.rb the same way CommandInterpreter's
|
|
24
38
|
# own DISPATCH_ORDER is; see that constant's doc comment for why this is
|
|
25
39
|
# hand-typed rather than read live off the meta-domain at every dispatch.
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
# assign_creation_attributes (an entity is never created through this
|
|
40
|
+
# `refuse_unknown_arguments`/`refuse_absent_arguments` now lead it, same
|
|
41
|
+
# position `AggregateDispatchOrder` holds them at (H1, above) — the only
|
|
42
|
+
# remaining difference from the aggregate order is no
|
|
43
|
+
# `assign_creation_attributes` (an entity is never created through this
|
|
30
44
|
# path).
|
|
31
45
|
DISPATCH_ORDER = Hecks::Vocabulary.symbols("EntityDispatchOrder")
|
|
32
46
|
|
|
47
|
+
# Same safety valve as `CommandInterpreter::MAX_STALE_WRITE_RETRIES` —
|
|
48
|
+
# see that constant's own comment.
|
|
49
|
+
MAX_STALE_WRITE_RETRIES = 5
|
|
50
|
+
|
|
33
51
|
# `instance` is the PARENT aggregate record (what gets saved and
|
|
34
52
|
# returned) ; `element`/`view` are the entity piece itself — `view`
|
|
35
53
|
# wraps `element` as it stood at `locate_element`, pre-mutation, and
|
|
@@ -56,6 +74,10 @@ module Hecks
|
|
|
56
74
|
# comment for the shared reasoning (Dispatcher#dry_run?'s own entry
|
|
57
75
|
# point). `step_save`/`step_emit` are the only two steps here that
|
|
58
76
|
# read it either.
|
|
77
|
+
# RETRIES THE WHOLE METHOD BODY on `StaleWrite` — same reasoning as
|
|
78
|
+
# `CommandInterpreter#call`'s own retry: a fresh `ctx`, a fresh
|
|
79
|
+
# `step_hydrate_parent`/`step_locate_element` re-reading current
|
|
80
|
+
# state.
|
|
59
81
|
def call(domain, aggregate, dotted, legacy_args, route: nil, with: nil, dry_run: false)
|
|
60
82
|
*entity_names, command_name = dotted.to_s.split(".")
|
|
61
83
|
if entity_names.empty?
|
|
@@ -70,13 +92,24 @@ module Hecks
|
|
|
70
92
|
entity: entity.hecks_name, command: command_name.inspect))
|
|
71
93
|
|
|
72
94
|
args = Routing.payload(command, with: with, legacy: legacy_args)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
95
|
+
attempt = 0
|
|
96
|
+
begin
|
|
97
|
+
ctx = Context.new(domain, aggregate, entity, entity_names.join("."), command, command_name, args)
|
|
98
|
+
ctx.chain = chain
|
|
99
|
+
ctx.route = route
|
|
100
|
+
ctx.dry_run = dry_run
|
|
101
|
+
ctx.plan = DependencyPlanning::Analyzer.call(aggregate: entity, command: command)
|
|
102
|
+
# RESOLVED HERE, ONCE — see CommandInterpreter#call's own comment;
|
|
103
|
+
# `step_hydrate_parent` reads `ctx.repository` without re-fetching.
|
|
104
|
+
ctx.repository = @registry.repository(domain, aggregate)
|
|
105
|
+
lock_id = Identity.best_effort(aggregate, args, route)
|
|
106
|
+
run_dispatch_order_with_isolation(DISPATCH_ORDER, ctx, lock_key_id: lock_id)
|
|
107
|
+
[ctx.instance, ctx.result, ctx.plan, ctx.persistence_outcome]
|
|
108
|
+
rescue StaleWrite
|
|
109
|
+
attempt += 1
|
|
110
|
+
retry if attempt < MAX_STALE_WRITE_RETRIES
|
|
111
|
+
raise
|
|
112
|
+
end
|
|
80
113
|
end
|
|
81
114
|
|
|
82
115
|
private
|
|
@@ -100,6 +133,31 @@ module Hecks
|
|
|
100
133
|
end
|
|
101
134
|
end
|
|
102
135
|
|
|
136
|
+
# `extra_identity_heads:` — every entity `ctx.chain` walks through, not
|
|
137
|
+
# just the root aggregate `ArgumentGate` already knows about. A
|
|
138
|
+
# two-hop dispatch (`Handler.Dispatch.Bind`) is addressed by BOTH
|
|
139
|
+
# hops' own identity, each read straight out of `args` by
|
|
140
|
+
# `EntityElement#element_of` — refusing those as unknown would refuse
|
|
141
|
+
# every legitimate nested-entity dispatch there is, the same reasoning
|
|
142
|
+
# `ArgumentGate#refuse_unknown_arguments`'s own header gives for `:id`/
|
|
143
|
+
# the root's `identity_heads`.
|
|
144
|
+
def step_refuse_unknown_arguments(ctx)
|
|
145
|
+
step(:refuse_unknown_arguments) {
|
|
146
|
+
refuse_unknown_arguments(ctx.domain, ctx.aggregate, ctx.command, ctx.args,
|
|
147
|
+
extra_identity_heads: ctx.chain.flat_map(&:identity_heads))
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# No `aggregate:` exemption to pass — that kwarg exists only for a
|
|
152
|
+
# port operation's own self-address (`ArgumentGate#refuse_absent_
|
|
153
|
+
# arguments`'s own comment); an entity command's chain identity never
|
|
154
|
+
# reaches `command.attributes` in the first place (resolved as
|
|
155
|
+
# addressing above, not as a declared fact), so there is nothing here
|
|
156
|
+
# for the exemption to need to strip.
|
|
157
|
+
def step_refuse_absent_arguments(ctx)
|
|
158
|
+
step(:refuse_absent_arguments) { refuse_absent_arguments(ctx.command, ctx.args) }
|
|
159
|
+
end
|
|
160
|
+
|
|
103
161
|
def step_normalize_args(ctx)
|
|
104
162
|
ctx.args = step(:normalize_args) { normalize_args(ctx.aggregate, ctx.command, ctx.args) }
|
|
105
163
|
end
|
|
@@ -113,7 +171,8 @@ module Hecks
|
|
|
113
171
|
end
|
|
114
172
|
|
|
115
173
|
def step_hydrate_parent(ctx)
|
|
116
|
-
ctx.repository
|
|
174
|
+
# `ctx.repository` is resolved once, in `#call`, before the
|
|
175
|
+
# isolation decision — not here any more.
|
|
117
176
|
ctx.instance = step(:hydrate_parent) {
|
|
118
177
|
parent(ctx.repository, ctx.aggregate, ctx.entity_name, ctx.command_name, ctx.args, ctx.route)
|
|
119
178
|
}
|
|
@@ -184,8 +243,20 @@ module Hecks
|
|
|
184
243
|
|
|
185
244
|
step(:save) do
|
|
186
245
|
@rules.resolve_state_references(ctx.domain, ctx.aggregate, ctx.instance.state)
|
|
187
|
-
|
|
188
|
-
|
|
246
|
+
# `expected_version:` — see CommandInterpreter#step_save's own
|
|
247
|
+
# comment: nil for a repository that isn't CAS-capable, or an
|
|
248
|
+
# instance never read from storage, either of which falls
|
|
249
|
+
# through to a plain save inside `AppendOnly#save`.
|
|
250
|
+
ctx.persistence_outcome = ctx.repository.save(ctx.instance, expected_version: ctx.instance.version)
|
|
251
|
+
if ctx.persistence_outcome.status == :stale
|
|
252
|
+
# NOT a `RefusalWording.render` call — see
|
|
253
|
+
# `CommandInterpreter#step_save`'s identical branch and
|
|
254
|
+
# `Runtime::StaleWrite`'s own comment.
|
|
255
|
+
raise(StaleWrite,
|
|
256
|
+
"#{ctx.command.hecks_name} on #{ctx.aggregate.hecks_name} " \
|
|
257
|
+
"(#{Identity.reading(ctx.aggregate)}: #{Rendering.describe(ctx.instance.id)}) lost a race — " \
|
|
258
|
+
"another write committed against this record after it was read")
|
|
259
|
+
end
|
|
189
260
|
end
|
|
190
261
|
end
|
|
191
262
|
|
data/lib/hecks/runtime/errors.rb
CHANGED
|
@@ -51,6 +51,28 @@ module Hecks
|
|
|
51
51
|
# itself, not whether the caller actually holds `policy`. See
|
|
52
52
|
# Runtime::TenantScope.
|
|
53
53
|
class Unauthorized < StandardError; end
|
|
54
|
+
# `corrects` names a past event this record must have already emitted
|
|
55
|
+
# (CommandBuilder#corrects_impl's own comment) — a fact the expression
|
|
56
|
+
# evaluator cannot check (it is not a predicate over the record's OWN
|
|
57
|
+
# fields, it is "did this exact record ever announce this"), so it is
|
|
58
|
+
# raised structurally, the same way AlreadyExists/NotFound are, rather
|
|
59
|
+
# than being expressible as an ordinary `given`. Raised by
|
|
60
|
+
# `CommandRules::Admissibility#enforce_correction_target`.
|
|
61
|
+
class NothingToCorrect < StandardError; end
|
|
62
|
+
|
|
63
|
+
# A RUNTIME FAULT, NOT A DOMAIN REFUSAL — deliberately absent from
|
|
64
|
+
# `DOMAIN_REFUSALS` below and from `vocabulary.bluebook`'s own
|
|
65
|
+
# `DomainRefusal` list. Raised when an optimistic-concurrency CAS write
|
|
66
|
+
# (`AppendOnly#save`'s `expected_version:`) finds the stored version has
|
|
67
|
+
# moved since this instance was read — someone else's write committed
|
|
68
|
+
# in between. `CommandInterpreter#call`/`EntityInterpreter#call` catch
|
|
69
|
+
# this themselves and retry the whole dispatch from a fresh hydrate, so
|
|
70
|
+
# `enforce_givens` re-evaluates against the now-current state; a caller
|
|
71
|
+
# only ever sees this escape when every retry is exhausted under
|
|
72
|
+
# sustained contention — an operational condition (many concurrent
|
|
73
|
+
# writers hammering one aggregate), not a business rule a domain author
|
|
74
|
+
# declared. See docs/decisions/ (concurrency-control ADR).
|
|
75
|
+
class StaleWrite < StandardError; end
|
|
54
76
|
|
|
55
77
|
# A Lambda-routed domain's own refusal (rust/host, `Runtime::
|
|
56
78
|
# RemoteDispatcher`), carrying Rust's own refusal text verbatim —
|
|
@@ -23,6 +23,15 @@ module Hecks
|
|
|
23
23
|
module Identity
|
|
24
24
|
module_function
|
|
25
25
|
|
|
26
|
+
# A hash read that decides which spelling of a key answers by
|
|
27
|
+
# PRESENCE, never by `||` — a bare `||` treats a genuinely-held
|
|
28
|
+
# `false` the same as an absent key and falls through to the other
|
|
29
|
+
# spelling, landing on `nil` instead of the real, stored answer.
|
|
30
|
+
def hash_lookup(hash, key)
|
|
31
|
+
sym = key.to_sym
|
|
32
|
+
hash.key?(sym) ? hash[sym] : hash[key]
|
|
33
|
+
end
|
|
34
|
+
|
|
26
35
|
# The head names the ATTRIBUTE and is consumed by whoever looked the value
|
|
27
36
|
# up; what is left is the walk down into it. A path with no fields to walk
|
|
28
37
|
# — an aggregate that declares no identity and falls back to `id` — hands
|
|
@@ -32,7 +41,7 @@ module Hecks
|
|
|
32
41
|
return held if fields.empty?
|
|
33
42
|
|
|
34
43
|
fields.reduce(Value.materialize(held)) do |dug, field|
|
|
35
|
-
dug.is_a?(Hash) ? (dug
|
|
44
|
+
dug.is_a?(Hash) ? hash_lookup(dug, field) : nil
|
|
36
45
|
end
|
|
37
46
|
end
|
|
38
47
|
|
|
@@ -83,7 +92,7 @@ module Hecks
|
|
|
83
92
|
# arrived whole has to be opened.
|
|
84
93
|
return held.to_s unless held.is_a?(Hash)
|
|
85
94
|
|
|
86
|
-
return rest.reduce(held) { |h, f| h.is_a?(Hash) ? (h
|
|
95
|
+
return rest.reduce(held) { |h, f| h.is_a?(Hash) ? hash_lookup(h, f) : nil }&.to_s
|
|
87
96
|
end
|
|
88
97
|
|
|
89
98
|
# Coerced against the identity ATTRIBUTE only when the caller actually
|
|
@@ -123,6 +132,25 @@ module Hecks
|
|
|
123
132
|
def reading(construct)
|
|
124
133
|
construct.identity_paths.join(", ")
|
|
125
134
|
end
|
|
135
|
+
|
|
136
|
+
# BEST-EFFORT, FOR A LOCK KEY ONLY — `Runtime::AggregateLock`'s own
|
|
137
|
+
# per-record striping needs SOME id to key on before dispatch has run
|
|
138
|
+
# far enough to hydrate for real, so this walks the identical chain
|
|
139
|
+
# `CommandInterpreter#hydrate_existing`/`#hydrate_prior_or_initial`
|
|
140
|
+
# and `EntityInterpreter#parent` already use to locate the real
|
|
141
|
+
# record — but wrapped to never raise. Choosing which Mutex to hold
|
|
142
|
+
# must never itself become a crash. `nil` means "could not resolve
|
|
143
|
+
# from the raw, pre-normalized payload this runs against" — the
|
|
144
|
+
# caller locks by aggregate type alone in that case (coarser, still
|
|
145
|
+
# correct, just less concurrent).
|
|
146
|
+
def best_effort(construct, args, route = nil, reference_key: nil)
|
|
147
|
+
route&.aggregate ||
|
|
148
|
+
of(construct, args) ||
|
|
149
|
+
from(construct, args, :id) ||
|
|
150
|
+
(reference_key && from(construct, args, reference_key))
|
|
151
|
+
rescue StandardError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
126
154
|
end
|
|
127
155
|
end
|
|
128
156
|
end
|
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
require_relative "value"
|
|
2
|
+
require_relative "identity"
|
|
2
3
|
|
|
3
4
|
module Hecks
|
|
4
5
|
module Runtime
|
|
5
6
|
class Instance
|
|
6
7
|
attr_reader :aggregate, :id
|
|
7
8
|
attr_accessor :state
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
# OUT-OF-BAND ADAPTER BOOKKEEPING, NOT DOMAIN STATE — the optimistic-
|
|
10
|
+
# concurrency version a CAS-capable adapter (Postgres today) stamps
|
|
11
|
+
# on a record it reads/writes, so a later `save` can assert "commit
|
|
12
|
+
# only if nobody has written since". Deliberately absent from
|
|
13
|
+
# `to_h`/`[]`/`[]=`/`method_missing` : a domain author never declares
|
|
14
|
+
# this, a `given`/`ensures`/`invariant` can never read it, and no
|
|
15
|
+
# adapter that doesn't understand it (Memory, Heki) ever sets it —
|
|
16
|
+
# `nil` there just means "no CAS attempted", which is exactly what a
|
|
17
|
+
# plain `save` already does. See docs/decisions/ (concurrency-control
|
|
18
|
+
# ADR) for the full mechanism.
|
|
19
|
+
attr_accessor :version
|
|
20
|
+
|
|
21
|
+
# `args:` — THE ORIGINAL COMMAND PAYLOAD, offered only by a fresh
|
|
22
|
+
# creation (`CommandInterpreter#hydrate_legacy_creation`/
|
|
23
|
+
# `#hydrate_complete_state`/`#hydrate_prior_or_initial`, each already
|
|
24
|
+
# holding it when they mint a brand-new record). See
|
|
25
|
+
# `materialize_identity!` for why a composite identity needs it.
|
|
26
|
+
def initialize(aggregate:, id:, state: nil, args: nil)
|
|
10
27
|
@aggregate = aggregate
|
|
11
28
|
@id = id
|
|
12
29
|
@state = state ? self.class.hydrate_with_defaults(aggregate, state) : self.class.defaults(aggregate)
|
|
13
|
-
|
|
30
|
+
@version = nil
|
|
31
|
+
materialize_identity!(args)
|
|
14
32
|
end
|
|
15
33
|
|
|
16
34
|
# Loading existing state runs the same default-fill a fresh instance
|
|
@@ -103,7 +121,29 @@ module Hecks
|
|
|
103
121
|
|
|
104
122
|
private
|
|
105
123
|
|
|
106
|
-
|
|
124
|
+
# M17 — a COMPOSITE identity (`identity_heads.size > 1`, e.g.
|
|
125
|
+
# `identified_by :branch_code, :box_number`) has no single
|
|
126
|
+
# `identified_by` to fall back to `:id` for — `@aggregate.identified_by`
|
|
127
|
+
# is nil the moment there is more than one head (`Behaviour::Identified
|
|
128
|
+
# #derive_identity`), so the single-head branch below never runs for
|
|
129
|
+
# it at all. A creating command that declares those heads as ordinary
|
|
130
|
+
# attributes but doesn't ALSO `sets` them (redundant with the identity
|
|
131
|
+
# the command's own args already named) used to persist every head as
|
|
132
|
+
# nil — the id correctly named the record, but the record's own
|
|
133
|
+
# attributes forgot what named it.
|
|
134
|
+
#
|
|
135
|
+
# Filled from `args`, never from splitting `@id` back apart — the
|
|
136
|
+
# same reason the single-head branch below won't guess a multi-path
|
|
137
|
+
# identifier from its joined string: `@id` is a display key, not a
|
|
138
|
+
# reversible serialization, and a composite's own separator can
|
|
139
|
+
# collide with a part's own text. `args` is only offered by a FRESH
|
|
140
|
+
# creation (`Instance.new`'s own `args:` comment); an existing record
|
|
141
|
+
# read back from storage has no args to lean on, and doesn't need
|
|
142
|
+
# one since a correctly-persisted record already carries its own
|
|
143
|
+
# heads.
|
|
144
|
+
def materialize_identity!(args = nil)
|
|
145
|
+
return materialize_composite_identity!(args) if @aggregate.identity_heads.size > 1
|
|
146
|
+
|
|
107
147
|
identity = @aggregate.identified_by || :id
|
|
108
148
|
attribute = @aggregate.attribute(identity)
|
|
109
149
|
return unless attribute && @state[identity].nil?
|
|
@@ -115,6 +155,21 @@ module Hecks
|
|
|
115
155
|
|
|
116
156
|
@state[identity] = Value.from_identifier(@aggregate, attribute, @id)
|
|
117
157
|
end
|
|
158
|
+
|
|
159
|
+
def materialize_composite_identity!(args)
|
|
160
|
+
return unless args
|
|
161
|
+
|
|
162
|
+
@aggregate.identity_paths.each do |path|
|
|
163
|
+
head = path.to_s.split(".").first.to_sym
|
|
164
|
+
attribute = @aggregate.attribute(head)
|
|
165
|
+
next unless attribute && @state[head].nil?
|
|
166
|
+
|
|
167
|
+
raw = Identity.from(@aggregate, args, path, value_owner: @aggregate)
|
|
168
|
+
next if raw.nil?
|
|
169
|
+
|
|
170
|
+
@state[head] = Value.from_identifier(@aggregate, attribute, raw)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
118
173
|
end
|
|
119
174
|
end
|
|
120
175
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require_relative "value"
|
|
2
|
+
require_relative "aggregate_lock"
|
|
2
3
|
|
|
3
4
|
module Hecks
|
|
4
5
|
module Runtime
|
|
@@ -43,6 +44,26 @@ module Hecks
|
|
|
43
44
|
order.each { |name| send(:"step_#{name}", ctx) }
|
|
44
45
|
end
|
|
45
46
|
|
|
47
|
+
# THE CONCURRENCY-CONTROL SPLIT — see docs/decisions/ (concurrency
|
|
48
|
+
# control ADR) for the full mechanism. A repository that declares
|
|
49
|
+
# `:optimistic_concurrency` (Postgres today) already closes the
|
|
50
|
+
# lost-update gap itself, via `step_save`'s CAS + `#call`'s own
|
|
51
|
+
# `StaleWrite` retry loop — an extra in-process lock here would be
|
|
52
|
+
# pointless overhead, not incorrect, so it's skipped for clarity.
|
|
53
|
+
# Every other repository (Heki, Memory — confirmed process-local
|
|
54
|
+
# data, never a second process writing the same store) gets a
|
|
55
|
+
# striped `Mutex` held for the WHOLE dispatch-order run, so a second
|
|
56
|
+
# thread's own hydrate can't start until the first thread's save has
|
|
57
|
+
# landed. `lock_key_id` is best-effort (`Identity.best_effort`) —
|
|
58
|
+
# `nil` still locks correctly, just coarser (by aggregate type).
|
|
59
|
+
def run_dispatch_order_with_isolation(order, ctx, lock_key_id:)
|
|
60
|
+
if ctx.repository.capabilities.include?(:optimistic_concurrency)
|
|
61
|
+
run_dispatch_order(order, ctx)
|
|
62
|
+
else
|
|
63
|
+
AggregateLock.for(ctx.domain, ctx.aggregate, lock_key_id).synchronize { run_dispatch_order(order, ctx) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
46
67
|
# Every declared attribute present in the payload passes the reference
|
|
47
68
|
# gate, then coercion — the same walk whether the command acts on an
|
|
48
69
|
# aggregate or on one of its entity's elements.
|
data/lib/hecks/runtime/loader.rb
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
require_relative "../facade/surface"
|
|
2
2
|
require_relative "../ports/loading"
|
|
3
|
+
require_relative "../ports/persistence"
|
|
3
4
|
require_relative "dispatcher"
|
|
4
5
|
require_relative "remote_dispatcher"
|
|
5
|
-
require_relative "
|
|
6
|
+
require_relative "boot_gates"
|
|
6
7
|
require_relative "registry"
|
|
7
8
|
|
|
8
9
|
module Hecks
|
|
@@ -42,20 +43,7 @@ module Hecks
|
|
|
42
43
|
loading.load_domain(directory, environment: environment)
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
# era (Postgres) must have created its partition and head views
|
|
47
|
-
# before any adapter opens them, and a refused era must refuse
|
|
48
|
-
# before any adapter touches data.
|
|
49
|
-
EraCheck.check!(registry, directory)
|
|
50
|
-
registry.verify!
|
|
51
|
-
# AFTER verify! (conservative — any wiring error surfaces first,
|
|
52
|
-
# not strictly required since resolution only needs the
|
|
53
|
-
# hecksagon binds, already loaded), BEFORE the dispatcher is
|
|
54
|
-
# built — repopulates `saga_instances` from whatever durable
|
|
55
|
-
# store each domain's own adapter answers with (§2-§4), so a
|
|
56
|
-
# process manager mid-flight at the last shutdown/crash/cold-
|
|
57
|
-
# start doesn't start this boot looking like it never began.
|
|
58
|
-
registry.rehydrate_sagas!
|
|
46
|
+
run_boot_gates!(registry, directory)
|
|
59
47
|
dispatcher = dispatcher_for(registry)
|
|
60
48
|
install_facade ? bind_runtime(dispatcher) : dispatcher
|
|
61
49
|
end
|
|
@@ -95,13 +83,66 @@ module Hecks
|
|
|
95
83
|
loading.load_selected(files, environment: environment)
|
|
96
84
|
end
|
|
97
85
|
|
|
98
|
-
|
|
99
|
-
registry.verify!
|
|
100
|
-
registry.rehydrate_sagas!
|
|
86
|
+
run_boot_gates!(registry, directory)
|
|
101
87
|
dispatcher = dispatcher_for(registry)
|
|
102
88
|
install_facade ? bind_runtime(dispatcher) : dispatcher
|
|
103
89
|
end
|
|
104
90
|
|
|
91
|
+
# ADR 0031 — replaces two previously-hardcoded, unconditional calls
|
|
92
|
+
# with a per-boot `BootGates` instance holding exactly the gates THIS
|
|
93
|
+
# registry's own bound adapters have a capability for. Ordering is
|
|
94
|
+
# preserved: era-checking (when a persistence plugin contributes one)
|
|
95
|
+
# still runs before `verify!`, saga rehydration still runs after
|
|
96
|
+
# (conservative — see `SagaPersistence#rehydrate_sagas!`'s own
|
|
97
|
+
# comment).
|
|
98
|
+
#
|
|
99
|
+
# ADR 0033 — this loader no longer names `EraCheck`, or any other
|
|
100
|
+
# era-specific class, at all. Every LOADED persistence plugin
|
|
101
|
+
# (`Ports::Persistence.each_plugin` — nothing here if nothing was
|
|
102
|
+
# ever `require`d) is asked to contribute its own `:pre_verify`/
|
|
103
|
+
# `:post_verify` gates generically; `:saga_rehydration` is the one
|
|
104
|
+
# gate core still registers directly, because ADR 0031 already
|
|
105
|
+
# proved it's not era-specific.
|
|
106
|
+
def self.run_boot_gates!(registry, directory)
|
|
107
|
+
gates = BootGates.new
|
|
108
|
+
Ports::Persistence.each_plugin { |plugin| plugin.contribute_boot_gates(registry, gates) }
|
|
109
|
+
check_compute_rules_backstop!(registry)
|
|
110
|
+
|
|
111
|
+
gates.run!(:pre_verify, registry, directory)
|
|
112
|
+
registry.verify!
|
|
113
|
+
|
|
114
|
+
gates.register(:saga_rehydration, ->(reg, _dir) { reg.rehydrate_sagas! }, phase: :post_verify) if
|
|
115
|
+
registry.hecksagons.each_key.any? { |domain| registry.saga_persistence(domain) != Ports::Persistence::NULL_SAGA_STORE }
|
|
116
|
+
gates.run!(:post_verify, registry, directory)
|
|
117
|
+
gates
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The one piece of the old, era-owned `check_compute_rules!` core
|
|
121
|
+
# still carries — deliberately thinner. `registry.translations` is
|
|
122
|
+
# plain `Bluebook::Translation`/`TranslationAggregate`/
|
|
123
|
+
# `TranslationCompute`/`TranslationRekey` data (`bluebook/
|
|
124
|
+
# translation.rb`, core, no era-specific class involved), so this
|
|
125
|
+
# needs nothing plugin-specific to ask "does anything declare a
|
|
126
|
+
# compute/rekey rule at all." A LOADED persistence plugin (e.g. the
|
|
127
|
+
# era plugin's own `:era_compute_rules` gate, registered above) runs
|
|
128
|
+
# the real, adapter-aware version of this check and refuses by name
|
|
129
|
+
# ("...is bound to Memory") long before this ever would; this only
|
|
130
|
+
# fires when nothing did, because nothing was loaded to.
|
|
131
|
+
def self.check_compute_rules_backstop!(registry)
|
|
132
|
+
return if Ports::Persistence.plugins_loaded?
|
|
133
|
+
|
|
134
|
+
registry.translations.each do |translation|
|
|
135
|
+
translation.aggregates.each do |aggregate|
|
|
136
|
+
next if aggregate.computes.empty? && aggregate.rekeys.empty?
|
|
137
|
+
|
|
138
|
+
raise WiringError,
|
|
139
|
+
"cannot boot #{translation.domain}::#{aggregate.name}: a compute/rekey rule is declared, but no " \
|
|
140
|
+
"persistence plugin that can interpret it is loaded (e.g. require " \
|
|
141
|
+
"\"hecks/ports/persistence/plugins/era\")"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
105
146
|
# `RemoteDispatcher` for a domain routed through Lambda,
|
|
106
147
|
# `Dispatcher` otherwise — the ONE place this decision gets
|
|
107
148
|
# made, so everything built on top (`Handle`, `AggregateDoor`,
|
|
@@ -177,11 +177,26 @@ module Hecks
|
|
|
177
177
|
|
|
178
178
|
ordered = ordered_elements(rows, declared.order_by, declared.null_semantics,
|
|
179
179
|
parent_key, entity.identity_heads)
|
|
180
|
-
|
|
180
|
+
# OFFSET FIRST, THEN LIMIT — same fix, same reasoning, as
|
|
181
|
+
# #interpret's own rows above. `entity_rows` is the ONLY engine
|
|
182
|
+
# for entity/sub-list queries, so a declared offset here silently
|
|
183
|
+
# vanished for every entity query, not merely one path among
|
|
184
|
+
# several.
|
|
185
|
+
skipped = declared.offset ? ordered.drop(resolve_query_value(declared.offset.value, args).to_i) : ordered
|
|
186
|
+
declared.limit ? skipped.first(resolve_query_value(declared.limit.value, args).to_i) : skipped
|
|
181
187
|
end
|
|
182
188
|
|
|
189
|
+
# FieldPath.dig, not a raw `element[clause.field.to_sym]` — an
|
|
190
|
+
# entity sub-list row is a plain hash merged from stored state
|
|
191
|
+
# (mixed string/symbol keys depending on adapter, per `#cell`'s own
|
|
192
|
+
# comment below), and a dotted `where` (`where "price.cents" < 100`)
|
|
193
|
+
# needs the same segment-by-segment walk every other query path
|
|
194
|
+
# already gets. Reading only the symbol spelling of the WHOLE
|
|
195
|
+
# dotted string as one key always missed — `element[:"price.cents"]`
|
|
196
|
+
# is never a real key — so a dotted where on an entity query
|
|
197
|
+
# silently matched nothing, on the only engine entity queries have.
|
|
183
198
|
def element_where_holds?(clause, element, args)
|
|
184
|
-
holds?(clause, element
|
|
199
|
+
holds?(clause, QuerySpecification::FieldPath.dig(element, clause.field), args)
|
|
185
200
|
end
|
|
186
201
|
|
|
187
202
|
# A row's own key, however the store spells it. A sub-list row is a plain hash
|
|
@@ -191,7 +206,13 @@ module Hecks
|
|
|
191
206
|
# to remove. It rides `comparable` for the same reason a where-clause does : an
|
|
192
207
|
# identity is a value object, and `to_s` on one is an OBJECT ADDRESS — a sort key
|
|
193
208
|
# that differs run to run, which is worse than the store order it replaced.
|
|
194
|
-
|
|
209
|
+
# `key?` decides which spelling answers, never `||` — a row whose
|
|
210
|
+
# value is a genuinely-held `false` must not fall through to the
|
|
211
|
+
# other spelling (usually absent) and land on `nil`.
|
|
212
|
+
def cell(row, key)
|
|
213
|
+
sym = key.to_sym
|
|
214
|
+
row.key?(sym) ? row[sym] : row[key.to_s]
|
|
215
|
+
end
|
|
195
216
|
|
|
196
217
|
# A sub-list row is identified by its PARENT and then its own key : two
|
|
197
218
|
# entities under different parents can share a sequence, so the parent has
|
|
@@ -244,10 +265,21 @@ module Hecks
|
|
|
244
265
|
|
|
245
266
|
def comparable(value) = QuerySpecification::Common::Comparison.comparable(value)
|
|
246
267
|
|
|
268
|
+
# FieldPath.dig, not a raw `record[field]` — `record` is an Instance
|
|
269
|
+
# here, and a dotted order_by (`order_by "price.cents"`) is a
|
|
270
|
+
# single symbol key (`:"price.cents"`) that never matches anything
|
|
271
|
+
# `Instance#[]` actually holds, so a dotted order_by silently sorted
|
|
272
|
+
# by all-nil (the identity tier alone deciding every tie) on this,
|
|
273
|
+
# the reference/no-native-hook engine — the same bug already fixed
|
|
274
|
+
# for `where` (see `where_holds?` above) and for entity rows (see
|
|
275
|
+
# `ordered_elements` below), just not yet for this, the aggregate-
|
|
276
|
+
# level order_by.
|
|
247
277
|
def ordered(records, order_by, null_semantics = nil)
|
|
248
278
|
field = order_by&.field
|
|
249
279
|
Ports::Query::Ordering.apply(records, order_by, null_semantics,
|
|
250
|
-
identity: ->(record) { record.id.to_s }) { |record|
|
|
280
|
+
identity: ->(record) { record.id.to_s }) { |record|
|
|
281
|
+
comparable(QuerySpecification::FieldPath.dig(record, field))
|
|
282
|
+
}
|
|
251
283
|
end
|
|
252
284
|
end
|
|
253
285
|
end
|
|
@@ -52,8 +52,16 @@ module Hecks
|
|
|
52
52
|
visible = normalized_scopes.find { |scope| scope.facts.key?(source) }
|
|
53
53
|
unless visible
|
|
54
54
|
names = normalized_scopes.map(&:name).join(" then ")
|
|
55
|
+
# WHAT IS VISIBLE, NAMED. A refusal that only says which name is
|
|
56
|
+
# missing sent a modeler guessing field after field
|
|
57
|
+
# (`number`, `reference`…) at a fan-out row that is addressed
|
|
58
|
+
# by ONE key — `account`, the lowercase aggregate — which
|
|
59
|
+
# nothing else in the domain spells out. The names each scope
|
|
60
|
+
# actually offers are the whole diagnosis; the refusal now
|
|
61
|
+
# lists them, scope by scope.
|
|
62
|
+
offered = normalized_scopes.map { |scope| "#{scope.name}: #{scope.facts.keys.sort.join(', ')}" }.join("; ")
|
|
55
63
|
raise UnknownArgument,
|
|
56
|
-
"#{label}'s with: reads :#{source}, which is not visible in #{names}"
|
|
64
|
+
"#{label}'s with: reads :#{source}, which is not visible in #{names} (visible — #{offered})"
|
|
57
65
|
end
|
|
58
66
|
visible.facts.fetch(source)
|
|
59
67
|
end
|
|
@@ -68,7 +68,7 @@ module Hecks
|
|
|
68
68
|
root_heads, other_heads = model.aggregate_heads.partition { |head| head[:aggregate] == model.reference_target }
|
|
69
69
|
projected = []
|
|
70
70
|
rows_by_as = {}
|
|
71
|
-
(root_heads + other_heads).each do |head|
|
|
71
|
+
(root_heads + order_other_heads(bluebook, root_heads, other_heads)).each do |head|
|
|
72
72
|
rows = if head[:aggregate] == model.reference_target
|
|
73
73
|
[fetch(bluebook, domain, head[:aggregate], reference_id)]
|
|
74
74
|
elsif rootless
|
|
@@ -112,6 +112,81 @@ module Hecks
|
|
|
112
112
|
end]
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
+
# THE ROOT-FIRST FIX'S OWN FIX — root-first alone only reaches one
|
|
116
|
+
# level: it guarantees the root is in `projected` before any other
|
|
117
|
+
# head is matched, but a CHAIN of non-root heads (a head that
|
|
118
|
+
# references another non-root head, not the root) is still
|
|
119
|
+
# matched against whatever declaration order happened to put in
|
|
120
|
+
# `projected` so far. `include Coupon` before `include Promotion`
|
|
121
|
+
# on a read model rooted at Item, where Coupon references
|
|
122
|
+
# Promotion (which references Item), silently returned an empty
|
|
123
|
+
# `coupons` array — Coupon's match ran while `projected` held only
|
|
124
|
+
# Item, one level short of what it needed.
|
|
125
|
+
#
|
|
126
|
+
# Fixed the same way root-first was: not by asking bluebook authors
|
|
127
|
+
# to declare `include` in dependency order (the same promise
|
|
128
|
+
# `read_model_builder.rb` already makes and this file is the one
|
|
129
|
+
# place obligated to keep), but by topologically sorting the
|
|
130
|
+
# non-root heads on their OWN declared reference fields before
|
|
131
|
+
# this method's runtime matching ever runs — Kahn's algorithm,
|
|
132
|
+
# picking ready heads in DECLARED order at each step so declaring
|
|
133
|
+
# order still governs whenever there is no dependency to break a
|
|
134
|
+
# tie. This generalizes root-first (a chain of length 1) to a
|
|
135
|
+
# chain of any depth, and to a head depending on more than one
|
|
136
|
+
# other head at once (not just a straight chain).
|
|
137
|
+
#
|
|
138
|
+
# A cycle among non-root heads (A references B which references A)
|
|
139
|
+
# has no valid topological order at all — falls back to the
|
|
140
|
+
# remaining heads' declared order rather than looping forever, the
|
|
141
|
+
# same "whichever runs first finds nothing" behaviour this method
|
|
142
|
+
# had for every non-root head before root-first existed.
|
|
143
|
+
def order_other_heads(bluebook, root_heads, other_heads)
|
|
144
|
+
resolved = root_heads.map { |head| head[:aggregate] }
|
|
145
|
+
remaining = other_heads.dup
|
|
146
|
+
ordered = []
|
|
147
|
+
until remaining.empty?
|
|
148
|
+
ready, blocked = remaining.partition do |head|
|
|
149
|
+
depends_on(bluebook, head, other_heads).all? { |target| resolved.include?(target) }
|
|
150
|
+
end
|
|
151
|
+
if ready.empty?
|
|
152
|
+
ordered.concat(remaining)
|
|
153
|
+
break
|
|
154
|
+
end
|
|
155
|
+
ordered.concat(ready)
|
|
156
|
+
resolved.concat(ready.map { |head| head[:aggregate] })
|
|
157
|
+
remaining = blocked
|
|
158
|
+
end
|
|
159
|
+
ordered
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Which OTHER declared (non-root) heads a head's own aggregate
|
|
163
|
+
# holds a reference field toward — the same relationship this
|
|
164
|
+
# file's runtime matching checks record-by-record, asked here
|
|
165
|
+
# statically, once, to order heads before any record is read.
|
|
166
|
+
#
|
|
167
|
+
# `head[:aggregate]` names whatever `include` was given — and
|
|
168
|
+
# `include` accepts a nested ENTITY (Member, nested under
|
|
169
|
+
# ValueObject ; Handler and Dispatch, nested under ProcessManager
|
|
170
|
+
# — bluebook.bluebook's own `WholeBluebook` read model includes
|
|
171
|
+
# all three) just as readily as a top-level aggregate.
|
|
172
|
+
# `bluebook.aggregate` only ever finds the latter
|
|
173
|
+
# (Behaviour::Chapter#aggregate searches `@aggregates`, which
|
|
174
|
+
# holds no entities), so it returns nil for an entity-headed
|
|
175
|
+
# include — a real case, not a malformed one. `records`, below,
|
|
176
|
+
# already treats that nil as "no rows of its own to fetch" ;
|
|
177
|
+
# a head with no rows of its own has nothing to check for a
|
|
178
|
+
# reference field either, so it depends on nothing here, the
|
|
179
|
+
# same as it always silently read empty before this file's
|
|
180
|
+
# topological sort existed.
|
|
181
|
+
def depends_on(bluebook, head, other_heads)
|
|
182
|
+
aggregate = bluebook.aggregate(head[:aggregate])
|
|
183
|
+
return [] unless aggregate
|
|
184
|
+
|
|
185
|
+
other_heads.reject { |other| other[:aggregate] == head[:aggregate] }
|
|
186
|
+
.select { |other| reference_fields(aggregate, other[:aggregate]).any? }
|
|
187
|
+
.map { |other| other[:aggregate] }
|
|
188
|
+
end
|
|
189
|
+
|
|
115
190
|
# `group_by`'s own declared fields, checked against the ONE
|
|
116
191
|
# many-side head they apply to (`seal_group_by` already refuses
|
|
117
192
|
# zero or several) — resolved here, once, rather than re-derived
|
|
@@ -70,6 +70,8 @@ module Hecks
|
|
|
70
70
|
"{type} is a composite identity — an identity must have exactly one field",
|
|
71
71
|
%w[TypeMismatch numeric_field] =>
|
|
72
72
|
"{type}.{field} expects {expected}, got {offered}",
|
|
73
|
+
%w[TypeMismatch non_finite_field] =>
|
|
74
|
+
"{type}.{field} must be a finite number, got {offered}",
|
|
73
75
|
%w[TypeMismatch pattern_mismatch] =>
|
|
74
76
|
"{type}.{field} must match {pattern}, got {offered}",
|
|
75
77
|
%w[TypeMismatch arithmetic_amount] =>
|