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
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
require_relative "
|
|
2
|
-
require_relative "../../
|
|
1
|
+
require_relative "../../../../../../ports/persistence/append_only"
|
|
2
|
+
require_relative "../../lineage"
|
|
3
3
|
|
|
4
4
|
module Hecks
|
|
5
5
|
module Translation
|
|
@@ -53,15 +53,14 @@ module Hecks
|
|
|
53
53
|
return if rekeyed
|
|
54
54
|
|
|
55
55
|
rules = Ports::Persistence::Lineage.from_declared(declared, aggregate.name)
|
|
56
|
-
|
|
57
|
-
.map { |path| path.to_s.split(".").first }
|
|
56
|
+
compute_paths = declared.computes.flat_map { |compute| [compute.from, compute.to] }.map(&:to_s)
|
|
58
57
|
|
|
59
58
|
before.each do |id, state|
|
|
60
59
|
next unless after.key?(id)
|
|
61
60
|
|
|
62
61
|
entry = Ports::Persistence::Entry.new(operation: "save", id: id, state: state.transform_keys(&:to_sym))
|
|
63
|
-
expected = normalize(rules.translate(entry).state)
|
|
64
|
-
actual = normalize(after[id])
|
|
62
|
+
expected = strip_compute_paths(normalize(rules.translate(entry).state), compute_paths)
|
|
63
|
+
actual = strip_compute_paths(normalize(after[id]), compute_paths)
|
|
65
64
|
next if expected == actual
|
|
66
65
|
|
|
67
66
|
diverged = (expected.keys | actual.keys).select { |key| expected[key] != actual[key] }
|
|
@@ -71,6 +70,32 @@ module Hecks
|
|
|
71
70
|
end
|
|
72
71
|
|
|
73
72
|
def normalize(state) = JSON.parse(JSON.generate(state))
|
|
73
|
+
|
|
74
|
+
# Exempts exactly the paths a compute owns, not the whole
|
|
75
|
+
# top-level attribute it happens to live under. A bare path
|
|
76
|
+
# ("price_cents") is itself the compute's entire value — dropping
|
|
77
|
+
# the whole top-level key is correct, there's nothing else there
|
|
78
|
+
# to check. A DOTTED path ("price.cents") only owns that one
|
|
79
|
+
# member of the value object it reaches into; every sibling
|
|
80
|
+
# member (e.g. "price.currency") is untouched by the compute and
|
|
81
|
+
# must stay subject to the equivalence check below. Blanket-
|
|
82
|
+
# dropping the whole top-level key for a dotted compute used to
|
|
83
|
+
# exempt the entire attribute — silent data loss elsewhere in the
|
|
84
|
+
# same value object (a migration that nulls or drops a sibling
|
|
85
|
+
# field) produced zero violations, defeating the one gate whose
|
|
86
|
+
# entire purpose is to catch exactly that.
|
|
87
|
+
def strip_compute_paths(state, paths)
|
|
88
|
+
paths.each do |path|
|
|
89
|
+
segments = path.split(".")
|
|
90
|
+
if segments.length == 1
|
|
91
|
+
state.delete(segments.first)
|
|
92
|
+
else
|
|
93
|
+
parent = segments[0..-2].reduce(state) { |node, segment| node.is_a?(Hash) ? node[segment] : nil }
|
|
94
|
+
parent.delete(segments.last) if parent.is_a?(Hash)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
state
|
|
98
|
+
end
|
|
74
99
|
end
|
|
75
100
|
end
|
|
76
101
|
end
|
data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb
RENAMED
|
@@ -33,7 +33,11 @@ module Hecks
|
|
|
33
33
|
path.to_s.split(".").reduce(state) do |node, segment|
|
|
34
34
|
break nil unless node.is_a?(Hash)
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
# `key?` decides which spelling answers, never `||` — a
|
|
37
|
+
# `false` genuinely held at this path must not fall through
|
|
38
|
+
# to the other spelling (usually absent) and read as `nil`,
|
|
39
|
+
# which would wrongly mark a `false`-valued attribute "unfed".
|
|
40
|
+
node.key?(segment) ? node[segment] : node[segment.to_sym]
|
|
37
41
|
end
|
|
38
42
|
end
|
|
39
43
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
require "tempfile"
|
|
2
|
-
require_relative "../
|
|
3
|
-
require_relative "
|
|
4
|
-
require_relative "../
|
|
2
|
+
require_relative "../era_guard"
|
|
3
|
+
require_relative "../../../../../runtime/registry"
|
|
4
|
+
require_relative "../storage_shape"
|
|
5
5
|
|
|
6
6
|
module Hecks
|
|
7
7
|
module Translation
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require_relative "../plugin"
|
|
2
|
+
require_relative "era/era_check"
|
|
3
|
+
require_relative "era/era_guard"
|
|
4
|
+
require_relative "era/era_tamper"
|
|
5
|
+
require_relative "era/storage_shape"
|
|
6
|
+
require_relative "era/lineage"
|
|
7
|
+
require_relative "era/postgres_era"
|
|
8
|
+
require_relative "era/translation"
|
|
9
|
+
|
|
10
|
+
module Hecks
|
|
11
|
+
module Ports
|
|
12
|
+
module Persistence
|
|
13
|
+
module Plugins
|
|
14
|
+
# ADR 0033 — requiring THIS FILE is installing the plugin. Nothing in
|
|
15
|
+
# Hecks core requires it; an app that binds `PostgresEra`, or wants
|
|
16
|
+
# schema-translation support at all, requires it explicitly — the
|
|
17
|
+
# same shape every adapter-specific spec fixture already uses to
|
|
18
|
+
# load one particular `.adapter` file rather than all of them.
|
|
19
|
+
module Era
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# `Runtime::Loader.run_boot_gates!` asks every loaded persistence
|
|
23
|
+
# plugin to contribute here, generically — it never mentions
|
|
24
|
+
# `EraCheck` or "era" by name. Two gates, both `:pre_verify`:
|
|
25
|
+
#
|
|
26
|
+
# `:era_compute_rules` — unconditional, whenever this plugin is
|
|
27
|
+
# loaded at all. A `compute`/`rekey` rule requires Postgres
|
|
28
|
+
# whatever adapter is actually bound (ADR 0031's own reasoning,
|
|
29
|
+
# unchanged) — this is the rich, adapter-aware version of that
|
|
30
|
+
# check; `Runtime::Loader`'s own structural backstop (plain
|
|
31
|
+
# `Bluebook::Translation` data, no plugin-specific class) only
|
|
32
|
+
# ever fires when NO persistence plugin is loaded at all.
|
|
33
|
+
#
|
|
34
|
+
# `:era_check` — conditional, exactly ADR 0031's own gate,
|
|
35
|
+
# unchanged: registered only when this registry has an aggregate
|
|
36
|
+
# actually bound to a lineage-capable adapter.
|
|
37
|
+
def contribute_boot_gates(registry, gates)
|
|
38
|
+
gates.register(:era_compute_rules, ->(reg, _dir) { Runtime::EraCheck.check_compute_rules_for_registry!(reg) }, phase: :pre_verify)
|
|
39
|
+
gates.register(:era_check, Runtime::EraCheck.method(:check_lineage!), phase: :pre_verify) if
|
|
40
|
+
Runtime::EraCheck.lineage_capable_registry?(registry)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Hecks::Ports::Persistence.register_plugin(:era, Hecks::Ports::Persistence::Plugins::Era)
|
|
@@ -9,7 +9,7 @@ module Hecks
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
require_relative "persistence/binding_policy"
|
|
12
|
-
require_relative "persistence/
|
|
12
|
+
require_relative "persistence/plugin"
|
|
13
13
|
require_relative "persistence/repository_factory"
|
|
14
14
|
require_relative "persistence/append_only"
|
|
15
15
|
require_relative "persistence/execution"
|
|
@@ -29,10 +29,30 @@ module Hecks
|
|
|
29
29
|
class Worker
|
|
30
30
|
attr_reader :projection
|
|
31
31
|
|
|
32
|
+
# The only two policies anything in this codebase ever passes
|
|
33
|
+
# (`bin/project`, every spec) — there is no third, legitimate
|
|
34
|
+
# "lenient append" policy on record anywhere. Before this, any
|
|
35
|
+
# value OTHER than the exact symbol `:strict` silently fell
|
|
36
|
+
# through the `consistent?` check below and appended onto
|
|
37
|
+
# divergent history without a word — not just a real typo like
|
|
38
|
+
# `:strikt`, but a caller-supplied String `"strict"` too (this
|
|
39
|
+
# duck-typed fine via `policy.to_sym`, but that was luck, not a
|
|
40
|
+
# contract: nothing here declared what a valid policy even was).
|
|
41
|
+
# Refusing loudly at construction, once, for anything outside
|
|
42
|
+
# this list turns a silent no-op into an immediate, named error
|
|
43
|
+
# — the "refuse rather than silently skip" reading of L1, since
|
|
44
|
+
# `:strict` really is meant to be the only enforcing contract.
|
|
45
|
+
VALID_POLICIES = %i[refresh strict].freeze
|
|
46
|
+
|
|
32
47
|
def initialize(authoritative, projection, policy: :refresh)
|
|
33
48
|
@authoritative = authoritative
|
|
34
49
|
@projection = projection
|
|
35
50
|
@policy = policy.to_sym
|
|
51
|
+
unless VALID_POLICIES.include?(@policy)
|
|
52
|
+
raise ArgumentError,
|
|
53
|
+
"unknown projection catch_up! policy #{@policy.inspect} — expected one of " \
|
|
54
|
+
"#{VALID_POLICIES.map(&:inspect).join(' or ')}"
|
|
55
|
+
end
|
|
36
56
|
end
|
|
37
57
|
|
|
38
58
|
# Invoke from a separate process or scheduler. The command-side write
|
|
@@ -48,7 +48,19 @@ module Hecks
|
|
|
48
48
|
# declares at least one command
|
|
49
49
|
# or query — everything you can
|
|
50
50
|
# DO to it and ASK about it,
|
|
51
|
+
# AND what each command WRITES,
|
|
51
52
|
# in one place
|
|
53
|
+
# <Name>_saga.mmd stateDiagram-v2 one per process_manager —
|
|
54
|
+
# its own states, and what
|
|
55
|
+
# each transition dispatches
|
|
56
|
+
# elsewhere in the domain
|
|
57
|
+
# frameworks.mmd flowchart every OTHER domain this one
|
|
58
|
+
# depends on — a shared
|
|
59
|
+
# framework it `uses_framework`,
|
|
60
|
+
# or a domain a policy reaches
|
|
61
|
+
# `across` — the one diagram
|
|
62
|
+
# here that looks OUTWARD past
|
|
63
|
+
# this domain's own boundary
|
|
52
64
|
#
|
|
53
65
|
# CONSTRUCT NAMES (aggregate/entity/command/event) ARE USED BARE,
|
|
54
66
|
# UNSANITIZED, as Mermaid node/entity ids — safe because this
|
|
@@ -100,6 +112,14 @@ module Hecks
|
|
|
100
112
|
files["#{holder.hecks_name}_surface.mmd"] = surface_diagram(bluebook, holder)
|
|
101
113
|
end
|
|
102
114
|
|
|
115
|
+
bluebook.process_managers.each do |saga|
|
|
116
|
+
files["#{saga.hecks_name}_saga.mmd"] = saga_diagram(bluebook, saga)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if (diagram = frameworks_diagram(bluebook, options[:hecksagon]))
|
|
120
|
+
files["frameworks.mmd"] = diagram
|
|
121
|
+
end
|
|
122
|
+
|
|
103
123
|
files
|
|
104
124
|
end
|
|
105
125
|
|
|
@@ -397,17 +417,226 @@ module Hecks
|
|
|
397
417
|
# anything. Command edges are solid ("does"); query edges are
|
|
398
418
|
# dotted ("asks") — the same solid/dotted split `ports.mmd`
|
|
399
419
|
# already uses for "routes to:" versus "exposes".
|
|
420
|
+
#
|
|
421
|
+
# A WRITE TARGET IS A PLAIN RECTANGLE — a sixth shape, the first
|
|
422
|
+
# here with no special bracket at all: an attribute is the
|
|
423
|
+
# smallest, most passive thing this vocabulary names, a single
|
|
424
|
+
# field living INSIDE the cylinder rather than a bounded thing of
|
|
425
|
+
# its own. `command.mutations` (`sets`/`increment`/`decrement`/
|
|
426
|
+
# `append`) was invisible everywhere before this — not just in a
|
|
427
|
+
# diagram, in ANY projection, including the prose ones — despite
|
|
428
|
+
# being the single densest fact in the whole IR (53 real
|
|
429
|
+
# mutations across pizzas + banking). `dispatch.mmd` draws what a
|
|
430
|
+
# command EMITS; this draws what it WRITES, the other half of
|
|
431
|
+
# "what actually happens" a command never showed before.
|
|
432
|
+
#
|
|
433
|
+
# THE SAME ATTRIBUTE NODE MERGES ACROSS COMMANDS — real in
|
|
434
|
+
# banking: `Account.Credit` and `Account.Debit` both point at the
|
|
435
|
+
# same `balance` node, the same "one node, several incoming
|
|
436
|
+
# edges" merge `read_models.mmd` already does for an aggregate
|
|
437
|
+
# fed by several read_models.
|
|
438
|
+
#
|
|
439
|
+
# THE LABEL NAMES THE REAL SOURCE, NOT JUST THE VERB — an
|
|
440
|
+
# increment/decrement/set almost always takes its value from an
|
|
441
|
+
# argument, but not always the SAME-NAMED one: real in banking,
|
|
442
|
+
# `Account.Credit`'s own `balance` is incremented by its
|
|
443
|
+
# `amount` argument, and `LedgerEntry.Amend`'s own `amount` is
|
|
444
|
+
# incremented by its `adjustment` argument. A literal source
|
|
445
|
+
# (pizzas' own `Order.Purchase` sets `status` to the literal
|
|
446
|
+
# `"sold"`, not an argument at all) is named as verbatim as
|
|
447
|
+
# every other fact in this file. `append`'s own fields carry no
|
|
448
|
+
# single source at all — its own field NAMES are the fact worth
|
|
449
|
+
# stating (real: `Order.AddTopping` appends `name, amount`).
|
|
400
450
|
def surface_diagram(bluebook, holder)
|
|
401
451
|
lines = holder.commands.map { |command| " #{holder.hecks_name}[(#{holder.hecks_name})] -->|does| #{command_node(holder.hecks_name, command.hecks_name)}" }
|
|
452
|
+
lines += holder.commands.flat_map { |command| command.mutations.map { |mutation| mutation_edge(holder, command, mutation) } }
|
|
402
453
|
lines += holder.queries.map { |query| " #{holder.hecks_name}[(#{holder.hecks_name})] -.->|asks| #{query_node(holder.hecks_name, query.hecks_name)}" }
|
|
403
454
|
|
|
404
|
-
subject = "#{holder.hecks_name}'s own declared commands and queries"
|
|
455
|
+
subject = "#{holder.hecks_name}'s own declared commands (and what each writes) and queries"
|
|
405
456
|
"#{header(bluebook.name, subject)}flowchart LR\n#{lines.uniq.join("\n")}\n"
|
|
406
457
|
end
|
|
407
458
|
|
|
408
459
|
def query_node(aggregate_name, query_name)
|
|
409
460
|
%(qry_#{aggregate_name}_#{query_name}{"#{aggregate_name}.#{query_name}"})
|
|
410
461
|
end
|
|
462
|
+
|
|
463
|
+
def mutation_edge(holder, command, mutation)
|
|
464
|
+
shape = mutation.to_h
|
|
465
|
+
label = mutation_label(shape)
|
|
466
|
+
target = attribute_node(holder.hecks_name, shape[:target])
|
|
467
|
+
%( #{command_node(holder.hecks_name, command.hecks_name)} -->|"#{label}"| #{target})
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def mutation_label(shape)
|
|
471
|
+
verb = "#{shape[:op]}s"
|
|
472
|
+
# `fields:` (not `source:`) IS the multi-binding shape
|
|
473
|
+
# (`Mutation#to_h`'s own `[:append, :delegate, :corrects]`
|
|
474
|
+
# branch) — checked by the KEY'S PRESENCE, not by re-listing
|
|
475
|
+
# which ops use it a second time here, the same lesson
|
|
476
|
+
# `Change.op`'s own `admits: Vocabulary::MutationOp` already
|
|
477
|
+
# drew (command.bluebook's own comment): a second list of "the
|
|
478
|
+
# ops that mean multi-binding" is exactly the kind of copy that
|
|
479
|
+
# drifts — `:delegate` already carried this shape with nothing
|
|
480
|
+
# here reading it correctly, caught only once `:corrects` gave
|
|
481
|
+
# banking's own real diagrams a fields:-shaped mutation to
|
|
482
|
+
# actually render.
|
|
483
|
+
detail = shape[:fields] ? shape[:fields].keys.join(", ") : mutation_source_detail(shape[:source])
|
|
484
|
+
"#{verb}: #{detail}"
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# A LITERAL VALUE CAN CONTAIN A DOUBLE QUOTE OF ITS OWN — real in
|
|
488
|
+
# banking: `Customer.Reinstate` sets `standing` to a rendered
|
|
489
|
+
# value-object literal, `{:value=>"good"}`, whose own embedded `"`
|
|
490
|
+
# broke this label's outer `|"..."|` quoting outright (caught by
|
|
491
|
+
# running the real generated output through mermaid.parse(), not
|
|
492
|
+
# by eye — the same way `read_models.mmd`'s own unquoted `[]` bug
|
|
493
|
+
# was caught). Swapped for a single quote here rather than
|
|
494
|
+
# escaped, the same "state it, don't invent it, just make it
|
|
495
|
+
# legal Mermaid" trade `read_models.mmd`'s own quoting fix made.
|
|
496
|
+
def mutation_source_detail(source)
|
|
497
|
+
case source[:kind]
|
|
498
|
+
when "literal" then "'#{source[:value].to_s.tr('"', "'")}'"
|
|
499
|
+
when "argument" then source[:name]
|
|
500
|
+
else source[:kind] # a source kind this file has no real corpus example of yet — named, not hidden
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def attribute_node(holder_name, attribute_name)
|
|
505
|
+
%(attr_#{holder_name}_#{attribute_name}[#{attribute_name}])
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# ── sagas -> stateDiagram-v2 ─────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
# A SAGA HAS A LIFECYCLE TOO — the same `stateDiagram-v2` shape
|
|
511
|
+
# `lifecycle_diagram` already draws, one file per process_manager
|
|
512
|
+
# the same way lifecycle is one file per lifecycle-bearing holder.
|
|
513
|
+
# What's different is the label: a lifecycle's own edge is labeled
|
|
514
|
+
# by the COMMAND that causes it (an aggregate transitions because
|
|
515
|
+
# something was DONE to it); a saga's edge is labeled by the EVENT
|
|
516
|
+
# that causes it (a saga advances because something HAPPENED,
|
|
517
|
+
# possibly nowhere near the saga itself) — the same command/event
|
|
518
|
+
# split `dispatch.mmd`'s own stadium/hexagon vocabulary already
|
|
519
|
+
# draws, here spent on which noun labels a stateDiagram-v2 edge
|
|
520
|
+
# instead.
|
|
521
|
+
#
|
|
522
|
+
# THE LABEL ALSO NAMES WHAT THE TRANSITION DISPATCHES — a fact no
|
|
523
|
+
# existing diagram states for a saga at all: a lifecycle's own
|
|
524
|
+
# edge only ever names the one command that caused it; a saga's
|
|
525
|
+
# edge can fire several commands at once (real in banking:
|
|
526
|
+
# Settlement's own AccountDebited handler dispatches both
|
|
527
|
+
# Transfer.Debited and Account.Credit). Confirmed real in the
|
|
528
|
+
# corpus: no saga dispatch ever declares a `to:`/`target_domain`
|
|
529
|
+
# of its own (unlike a policy's `across`) — every command a saga
|
|
530
|
+
# fires lands inside its own bluebook chapter, so this never needs
|
|
531
|
+
# `dispatch.mmd`'s own "triggers in X" cross-domain label.
|
|
532
|
+
#
|
|
533
|
+
# THE COMPENSATING LEG READS LIKE ANY OTHER — its own trigger is
|
|
534
|
+
# the literal string "refused" (`ProcessManager::REFUSED`, this
|
|
535
|
+
# language's own Trigger vocabulary), not invented text: a
|
|
536
|
+
# dispatch declined is exactly as real a cause of a state
|
|
537
|
+
# transition as an event announced, and the diagram states it
|
|
538
|
+
# exactly as verbatim as every other edge here does.
|
|
539
|
+
def saga_diagram(bluebook, saga)
|
|
540
|
+
edges = saga.handlers.map { |handler| saga_edge(handler, saga) }
|
|
541
|
+
|
|
542
|
+
subject = "#{saga.hecks_name}'s own declared states and what each transition dispatches " \
|
|
543
|
+
"(starts on #{saga.starts_on}, ends on #{saga.ends_on})"
|
|
544
|
+
<<~MERMAID
|
|
545
|
+
#{header(bluebook.name, subject)}stateDiagram-v2
|
|
546
|
+
[*] --> #{saga.states.first}
|
|
547
|
+
#{edges.join("\n")}
|
|
548
|
+
MERMAID
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# THE REFUSED EDGE'S OWN DISPATCH LIST IS PARTLY DERIVED NOW —
|
|
552
|
+
# per-dispatch saga compensation (`compensates`) moved a saga's own
|
|
553
|
+
# compensating dispatches OFF the hand-written `on :refused` leg
|
|
554
|
+
# and onto whichever forward dispatch each one undoes, so
|
|
555
|
+
# `handler.dispatches` alone would render an EMPTY compensating
|
|
556
|
+
# edge for any saga using it — accurate to the DECLARATION, wrong
|
|
557
|
+
# about what the runtime actually does at refusal (it derives and
|
|
558
|
+
# fires every declared `compensates`, newest first). `saga` is
|
|
559
|
+
# passed through for exactly this — only the REFUSED handler needs
|
|
560
|
+
# it, every other edge's own `handler.dispatches` already says
|
|
561
|
+
# everything real about it.
|
|
562
|
+
def saga_edge(handler, saga)
|
|
563
|
+
label = handler.event_type
|
|
564
|
+
# DERIVED FIRST, then the hand-written body — the same order
|
|
565
|
+
# `SagaInterpreter#unwind` actually runs them in (every
|
|
566
|
+
# completed leg's own `compensates` before this leg's own
|
|
567
|
+
# hand-written dispatches), not declaration order on the page.
|
|
568
|
+
dispatched = handler.event_type == Bluebook::ProcessManager::REFUSED ? derived_compensations(saga) : []
|
|
569
|
+
dispatched += handler.dispatches.map(&:command_name)
|
|
570
|
+
label += " / dispatches #{dispatched.join(', ')}" unless dispatched.empty?
|
|
571
|
+
|
|
572
|
+
" #{handler.from_state} --> #{handler.to_state}: #{label}"
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Every `compensates` any forward dispatch in this saga declares,
|
|
576
|
+
# declaration order — the same commands `SagaInterpreter#unwind`
|
|
577
|
+
# derives and fires (newest-first, at actual refusal time; this
|
|
578
|
+
# diagram states them in declaration order, since it draws the
|
|
579
|
+
# saga's own shape, not one instance's own runtime history).
|
|
580
|
+
def derived_compensations(saga)
|
|
581
|
+
saga.handlers.flat_map { |handler| handler.dispatches.filter_map { |dispatch| dispatch.compensates&.command_name } }
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# ── frameworks -> flowchart ─────────────────────────────────────
|
|
585
|
+
|
|
586
|
+
# EVERY OTHER DIAGRAM IN THIS FILE STAYS INSIDE ONE DOMAIN'S OWN
|
|
587
|
+
# BOUNDARY — this is the one that steps outside it. A real domain
|
|
588
|
+
# depends on another domain's own aggregates in exactly two ways:
|
|
589
|
+
# `uses_framework "X"` in its `.hecksagon` (`Hecksagon#framework_
|
|
590
|
+
# members`), which loads X's whole bluebook into THIS registry,
|
|
591
|
+
# unconditionally, the moment this domain boots; or a policy's own
|
|
592
|
+
# `across "X"` (`Policy#target_domain`), which only reaches X when
|
|
593
|
+
# the policy's declared event actually fires. Same underlying
|
|
594
|
+
# fact `dispatch.mmd`'s own `trigger_edge` already draws from the
|
|
595
|
+
# command's side ("triggers in X") — this draws it again from the
|
|
596
|
+
# DOMAIN's side, next to the structural `uses_framework` fact
|
|
597
|
+
# `dispatch.mmd` never sees at all (that lives in the `.hecksagon`,
|
|
598
|
+
# which no other diagram here is handed).
|
|
599
|
+
#
|
|
600
|
+
# NEITHER THIS DOMAIN NOR EACH DEPENDENCY GETS THE holders() TREATMENT
|
|
601
|
+
# — a whole domain is drawn as ONE cylinder, the same "a bounded,
|
|
602
|
+
# addressable thing" shape every other diagram here already spends
|
|
603
|
+
# on a single aggregate, just scaled up one level: a domain is a
|
|
604
|
+
# bigger box the same kind of box lives inside.
|
|
605
|
+
#
|
|
606
|
+
# DOTTED FOR `attaches`, SOLID FOR `reaches across` — the reverse
|
|
607
|
+
# of which fact is "always true" between the two: attaching a
|
|
608
|
+
# framework is a standing declaration, true every time this domain
|
|
609
|
+
# boots, so it gets the same dotted "this always belongs" treatment
|
|
610
|
+
# `ports.mmd` gives an aggregate's own `-.->|exposes|` edge.
|
|
611
|
+
# Reaching across only happens when a real policy actually fires —
|
|
612
|
+
# the same solid edge `dispatch.mmd`'s own `trigger_edge` already
|
|
613
|
+
# draws for the identical fact, kept solid here so the same
|
|
614
|
+
# relationship reads the same way in both diagrams.
|
|
615
|
+
#
|
|
616
|
+
# `options[:hecksagon]` IS THE ONE DIAGRAM IN THIS FILE THAT NEEDS
|
|
617
|
+
# MORE THAN `bluebook` — `framework_members` lives on the
|
|
618
|
+
# `Hecksagon`, a sibling IR object `bin/project_diagrams` already
|
|
619
|
+
# has in hand (`registry.hecksagon(chapter_name)`) but `bluebook`
|
|
620
|
+
# itself carries no reference to. No hecksagon handed in (an older
|
|
621
|
+
# caller, or a spec that doesn't care) just means no frameworks.mmd
|
|
622
|
+
# — same "nothing to state" skip every other diagram here already
|
|
623
|
+
# takes when its own underlying data is empty.
|
|
624
|
+
def frameworks_diagram(bluebook, hecksagon)
|
|
625
|
+
return nil unless hecksagon
|
|
626
|
+
|
|
627
|
+
lines = hecksagon.framework_members.map { |name| domain_edge(bluebook.name, "attaches", name, dotted: true) }
|
|
628
|
+
lines += bluebook.policies.filter_map(&:target_domain).uniq
|
|
629
|
+
.map { |name| domain_edge(bluebook.name, "reaches across", name, dotted: false) }
|
|
630
|
+
return nil if lines.empty?
|
|
631
|
+
|
|
632
|
+
subject = "#{bluebook.name}'s own declared uses_framework and cross-domain policy targets"
|
|
633
|
+
"#{header(bluebook.name, subject)}flowchart LR\n#{lines.uniq.join("\n")}\n"
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def domain_edge(from, label, to, dotted:)
|
|
637
|
+
arrow = dotted ? "-.->" : "-->"
|
|
638
|
+
%( #{from}[(#{from})] #{arrow}|#{label}| #{to}[(#{to})])
|
|
639
|
+
end
|
|
411
640
|
end
|
|
412
641
|
end
|
|
413
642
|
end
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
require_relative "../
|
|
1
|
+
require_relative "../ports/persistence"
|
|
2
2
|
require_relative "../projector"
|
|
3
3
|
|
|
4
4
|
module Hecks
|
|
@@ -25,7 +25,20 @@ module Hecks
|
|
|
25
25
|
|
|
26
26
|
module_function
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
# ADR 0033 — `Runtime::StorageShape` lives in the era persistence
|
|
29
|
+
# plugin now; the `:shape` projection itself stays registered
|
|
30
|
+
# unconditionally (the seam `spec/projector_seam_spec.rb` enforces
|
|
31
|
+
# is "every file here registers," not "every projection always
|
|
32
|
+
# succeeds") but refuses clearly if asked to run with the plugin
|
|
33
|
+
# unloaded, rather than raising on an undefined constant.
|
|
34
|
+
def call(bluebook:, options: {})
|
|
35
|
+
unless Ports::Persistence.plugin?(:era)
|
|
36
|
+
raise "the :shape projection needs the era persistence plugin loaded " \
|
|
37
|
+
"(require \"hecks/ports/persistence/plugins/era\")"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
Runtime::StorageShape.project(bluebook)
|
|
41
|
+
end
|
|
29
42
|
end
|
|
30
43
|
end
|
|
31
44
|
end
|
|
@@ -192,7 +192,8 @@ module Hecks
|
|
|
192
192
|
end
|
|
193
193
|
|
|
194
194
|
{ verb: fqn(bluebook, aggregate, command, entity), kind: :command,
|
|
195
|
-
summary: command.goal, role: command.role,
|
|
195
|
+
summary: command.goal, role: command.role, role_gated: !command.role.to_s.empty?,
|
|
196
|
+
creates: command.creates?,
|
|
196
197
|
receiver: receiver, legacy_receiver: (receiver == :aggregate ? :id : nil),
|
|
197
198
|
legacy_arguments: legacy_arguments,
|
|
198
199
|
refusals: refusals(command, holder), arguments: arguments }
|
|
@@ -227,7 +228,15 @@ module Hecks
|
|
|
227
228
|
# names it short, which is the same split `shorten` already makes.
|
|
228
229
|
{ verb: [fqn(bluebook, aggregate, operation).sub(/\.[^.]+\z/, ""), port.name, operation.hecks_name].join("."),
|
|
229
230
|
kind: :command, creates: false, receiver: :aggregate, refusals: [],
|
|
231
|
+
# `role:` HERE IS DESCRIPTIVE TEXT, NOT AN AUTHORIZATION GATE —
|
|
232
|
+
# who calls whom through the port, for `--help`/`verb_help`'s
|
|
233
|
+
# "issued by" line. A port operation never reaches
|
|
234
|
+
# `CommandRules::Authorization#refuse_role_mismatch` (only
|
|
235
|
+
# `CommandInterpreter`/`EntityInterpreter` call it, never the port
|
|
236
|
+
# dispatch path), so `role_gated: false` always, unlike
|
|
237
|
+
# `command_spec` where the same key name means a real one.
|
|
230
238
|
role: operation.outbound? ? "#{aggregate.hecks_name} asking #{port.name}" : "#{port.name} telling #{aggregate.hecks_name}",
|
|
239
|
+
role_gated: false,
|
|
231
240
|
summary: port_summary(port, operation), arguments: arguments }
|
|
232
241
|
end
|
|
233
242
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
require "json"
|
|
2
|
-
require_relative "../
|
|
3
|
-
require_relative "../translation/rule_compiler"
|
|
2
|
+
require_relative "../ports/persistence"
|
|
4
3
|
|
|
5
4
|
module Hecks
|
|
6
5
|
module Projector
|
|
@@ -30,7 +29,15 @@ module Hecks
|
|
|
30
29
|
# Reuses `Runtime::EraCheck`'s own capability predicates rather than
|
|
31
30
|
# re-deriving them — the boot-time gate and this export must never
|
|
32
31
|
# answer differently for the same aggregate.
|
|
32
|
+
#
|
|
33
|
+
# ADR 0033 — `Runtime::EraCheck` lives in the (optional) era
|
|
34
|
+
# persistence plugin now; unloaded, this answers exactly what it
|
|
35
|
+
# already answers for a domain with nothing lineage-capable bound —
|
|
36
|
+
# `capable_aggregates: []` — rather than raising on an undefined
|
|
37
|
+
# constant.
|
|
33
38
|
def lineage(registry, domain_name)
|
|
39
|
+
return { capable_aggregates: [] } unless Ports::Persistence.plugin?(:era)
|
|
40
|
+
|
|
34
41
|
bluebook = registry.bluebooks.fetch(domain_name)
|
|
35
42
|
capable = bluebook.aggregates.select do |aggregate|
|
|
36
43
|
adapter_name = Runtime::EraCheck.adapter_for(registry, domain_name, aggregate)
|
|
@@ -130,7 +137,17 @@ module Hecks
|
|
|
130
137
|
# expression` is nil unless this edge rekeys — the bare
|
|
131
138
|
# `aggregate_id` passthrough head_compiler.rb itself falls back to
|
|
132
139
|
# for the overwhelming common case.
|
|
140
|
+
# ADR 0033 — `Translation::RuleCompiler` lives in the era plugin;
|
|
141
|
+
# unloaded, there is nothing that can compile this SQL, so this
|
|
142
|
+
# falls back to the bare declared fields (`translation_aggregate`
|
|
143
|
+
# alone) rather than raising on an undefined constant. A consumer
|
|
144
|
+
# embedding this JSON without the era plugin loaded gets the same
|
|
145
|
+
# declared-rules shape, just without precompiled SQL to execute —
|
|
146
|
+
# consistent with there being no mint/audit machinery to run it
|
|
147
|
+
# against either.
|
|
133
148
|
def compiled_translation_aggregate(aggregate)
|
|
149
|
+
return translation_aggregate(aggregate) unless Ports::Persistence.plugin?(:era)
|
|
150
|
+
|
|
134
151
|
translation_aggregate(aggregate).merge(
|
|
135
152
|
compiled_state_expression: Translation::RuleCompiler.compile_rules(aggregate),
|
|
136
153
|
compiled_id_expression: Translation::RuleCompiler.rekeyed?(aggregate) ? Translation::RuleCompiler.compile_id_expression(aggregate) : nil
|
data/lib/hecks/query_ir.rb
CHANGED
|
@@ -233,6 +233,25 @@ module Hecks
|
|
|
233
233
|
# hand whether this is ALREADY a chapter-wide reference before
|
|
234
234
|
# assuming it's fresh duplication," not as an automatic signal
|
|
235
235
|
# either way.
|
|
236
|
+
#
|
|
237
|
+
# THE IDENTICAL GAP, ONE LEVEL DOWN: chapter-wide ENTITY-scoped
|
|
238
|
+
# sharing (`EntityBuilder#given`'s own bare form,
|
|
239
|
+
# `docs/implemented/resolution-rules/chapter-entity-given.md`) hits this same wall for
|
|
240
|
+
# the same structural reason — `SafeDepositBox.Visit` still shows
|
|
241
|
+
# as its own "(declared)" owner here even after becoming a bare
|
|
242
|
+
# reference to `Account.LedgerEntry`'s declaration, because a piece
|
|
243
|
+
# resolving a chapter-wide reference still write-throughs the
|
|
244
|
+
# resolved `Given` into its own `@named_givens` (so ITS OWN
|
|
245
|
+
# commands can read it back locally without a second hop). This is
|
|
246
|
+
# not a NEW limitation this feature introduces — it is the exact
|
|
247
|
+
# same IR-cannot-distinguish-declared-from-referenced fact, one
|
|
248
|
+
# scope wider. `bin/query_ir duplicates` confirms this directly:
|
|
249
|
+
# `Account.LedgerEntry (declared)` and `SafeDepositBox.Visit
|
|
250
|
+
# (declared)` both appear under the same `given: "customer is
|
|
251
|
+
# active"` group — verify by hand, same as the aggregate-level
|
|
252
|
+
# case above, before assuming a group naming two pieces under
|
|
253
|
+
# different aggregates is fresh duplication rather than an already-
|
|
254
|
+
# resolved chapter-wide reference.
|
|
236
255
|
def declaration_count(rules)
|
|
237
256
|
declared = rules.select { |r| r.location.end_with?(" (declared)") }
|
|
238
257
|
declared_owners = declared.map { |r| owner_of(r.location) }.to_set
|
|
@@ -25,12 +25,24 @@ module Hecks
|
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# M3 — an UNDECLARED (`native`) null policy used to render no
|
|
29
|
+
# `NULLS ...` clause at all here, leaving each dialect's own
|
|
30
|
+
# default to decide: Postgres puts nulls LAST on ASC (and FIRST
|
|
31
|
+
# on DESC), while `#order` above — this same "native" default,
|
|
32
|
+
# for Memory — puts nulls FIRST on ASC (and LAST on DESC), the
|
|
33
|
+
# SQLite convention. Same query, same data, different row order
|
|
34
|
+
# depending only on which adapter ran it. Rendered explicitly
|
|
35
|
+
# here instead, so an undeclared policy means the SAME total
|
|
36
|
+
# order everywhere rather than "whatever this store already does"
|
|
37
|
+
# — matching `#order`'s own default rather than the other way
|
|
38
|
+
# round, since that default is unconditional (Memory/Heki have no
|
|
39
|
+
# dialect to defer to) and SQLite already agrees with it natively.
|
|
28
40
|
def sql_order(expression, direction, policy)
|
|
29
41
|
direction = direction.to_s.downcase == "desc" ? "DESC" : "ASC"
|
|
30
42
|
nulls = case policy&.mode.to_s
|
|
31
43
|
when "first" then " NULLS FIRST"
|
|
32
44
|
when "last" then " NULLS LAST"
|
|
33
|
-
else ""
|
|
45
|
+
else direction == "DESC" ? " NULLS LAST" : " NULLS FIRST"
|
|
34
46
|
end
|
|
35
47
|
"#{expression} #{direction}#{nulls}, id #{direction}"
|
|
36
48
|
end
|
|
@@ -31,7 +31,11 @@ module Hecks
|
|
|
31
31
|
# plain row hash); the rest read through whatever each step holds. A
|
|
32
32
|
# stored nested value object is a plain hash by the time it is read
|
|
33
33
|
# back, keyed by symbol in memory and by string off a wire decode,
|
|
34
|
-
# so both spellings are tried
|
|
34
|
+
# so both spellings are tried — `key?` first, never `||`, because
|
|
35
|
+
# `||` falls through a genuinely-stored `false` to the OTHER
|
|
36
|
+
# spelling (usually absent) and returns `nil` instead. The seal
|
|
37
|
+
# admits boolean leaves (`SCALAR_PRIMITIVES` below), so a `false`
|
|
38
|
+
# here is a real, held answer, not a missing one.
|
|
35
39
|
def dig(holder, field)
|
|
36
40
|
return nil if field.nil?
|
|
37
41
|
|
|
@@ -40,7 +44,21 @@ module Hecks
|
|
|
40
44
|
|
|
41
45
|
def read(current, segment)
|
|
42
46
|
return nil if current.nil?
|
|
43
|
-
|
|
47
|
+
|
|
48
|
+
if current.is_a?(Hash)
|
|
49
|
+
sym = segment.to_sym
|
|
50
|
+
return current.key?(sym) ? current[sym] : current[segment]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# M5 — "or nil, never raise" is this method's whole contract, and
|
|
54
|
+
# an Array broke it: `Array#[]` demands an Integer index, so
|
|
55
|
+
# `current[segment]` (a String) raised `TypeError` straight
|
|
56
|
+
# through `dig` instead of answering nil. A dotted path stepping
|
|
57
|
+
# INTO a list_of attribute (`where "tags.name" == "x"` against a
|
|
58
|
+
# bare list, rather than each element) has no single member a
|
|
59
|
+
# bare index would name anyway — nil is the honest answer, the
|
|
60
|
+
# same one a dangling reference or a missing key already gets.
|
|
61
|
+
return nil if current.is_a?(Array)
|
|
44
62
|
|
|
45
63
|
current[segment]
|
|
46
64
|
end
|
|
@@ -7,11 +7,13 @@ module Hecks
|
|
|
7
7
|
# sibling, not its member. FieldPath walks a SHAPE, which cannot
|
|
8
8
|
# loop, and answers nil, never raising, because there is nothing
|
|
9
9
|
# left to say beyond "not found." HopPath walks the REFERENCE
|
|
10
|
-
# GRAPH instead
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
10
|
+
# GRAPH instead — `BluebookBuilder#validate_no_bidirectional_references!`
|
|
11
|
+
# refuses any reference CYCLE at declaration time (ADR 0025,
|
|
12
|
+
# "References" — widened from a direct pair to any ring, DFS over
|
|
13
|
+
# the chapter's own reference graph), so what's left to guard here
|
|
14
|
+
# is depth, not cycles: MAX_HOPS below, and this module's own
|
|
15
|
+
# `refusal` states for a target the chapter doesn't resolve. A walk
|
|
16
|
+
# here still needs to say WHY it stopped, not just that it did.
|
|
15
17
|
#
|
|
16
18
|
# Every method below takes an ATTRIBUTE ARRAY, never a "shape"
|
|
17
19
|
# object — deliberately, because the two real callers hold their
|