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,3 +1,14 @@
|
|
|
1
|
+
# ADR 0033 — the `PostgresEra` Ruby CLASS moved into the loadable era
|
|
2
|
+
# persistence plugin (`lib/hecks/ports/persistence/plugins/era/`); this
|
|
3
|
+
# declaration stays here, unmoved. `Adapters::Folder#load_library` globs
|
|
4
|
+
# `.adapter` files by DIRECTORY, not by require path, on every boot,
|
|
5
|
+
# regardless of which adapters a given domain actually binds — the same
|
|
6
|
+
# reason every other adapter's own `.adapter` file lives beside this one.
|
|
7
|
+
# Pure metadata (which port, which fields) — no era-specific code, and
|
|
8
|
+
# harmless for a domain that never binds `persisted_by "PostgresEra"`.
|
|
9
|
+
# `registry.adapter_class("PostgresEra")` is only ever resolved for a
|
|
10
|
+
# domain that actually binds to it, at which point the plugin must
|
|
11
|
+
# already be `require`d for the class to exist.
|
|
1
12
|
Hecks.adapter "PostgresEra" do
|
|
2
13
|
port "persistence"
|
|
3
14
|
field :database
|
|
@@ -14,6 +14,23 @@ module Hecks
|
|
|
14
14
|
end
|
|
15
15
|
lifecycle = @aggregate.lifecycle
|
|
16
16
|
fields << { name: lifecycle.field, attribute: nil, sql_type: "TEXT" } if lifecycle && !fields.any? { |field| field[:name] == lifecycle.field }
|
|
17
|
+
# `projects` FIELDS (S12, ADR 0025) ARE A LOCAL COLUMN TOO —
|
|
18
|
+
# `CommandInterpreter#seed_projected_fields`/`RebuildSweep`
|
|
19
|
+
# both write one straight into `Instance#state` the same as
|
|
20
|
+
# any other field, so a column has to exist to hold it or
|
|
21
|
+
# `save`'s own `columns =`/`values =` build (this file, not
|
|
22
|
+
# here) silently drops it on every write. No `attribute` of
|
|
23
|
+
# their own to carry (`ProjectedField` is a bare name/
|
|
24
|
+
# reference/remote_field triple, not a typed attribute) —
|
|
25
|
+
# treated as a raw scalar, same as the lifecycle field just
|
|
26
|
+
# above: every real corpus use projects a status/lifecycle
|
|
27
|
+
# string, and `encode_field`/`decode` both already pass a
|
|
28
|
+
# `attribute: nil` field through untouched, not JSON-encoded.
|
|
29
|
+
@aggregate.projected_fields.each do |field|
|
|
30
|
+
next if fields.any? { |f| f[:name] == field.name }
|
|
31
|
+
|
|
32
|
+
fields << { name: field.name, attribute: nil, sql_type: "TEXT" }
|
|
33
|
+
end
|
|
17
34
|
fields
|
|
18
35
|
end
|
|
19
36
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
require_relative "../../../runtime/instance"
|
|
2
2
|
require_relative "../../../runtime/value"
|
|
3
|
+
require_relative "../../../runtime/errors"
|
|
4
|
+
require_relative "../../../runtime/refusal_wording"
|
|
5
|
+
require_relative "../../../rendering"
|
|
3
6
|
require_relative "../../../ports/query/in_memory"
|
|
4
7
|
|
|
5
8
|
# The subclass needs its parent — and sqlite.rb requires this file at
|
|
@@ -29,6 +32,34 @@ module Hecks
|
|
|
29
32
|
# Execute a declared read model against the projected aggregate-head
|
|
30
33
|
# tables. The report shape is assembled from SQL-selected rows, rather
|
|
31
34
|
# than scanning repositories and matching references in Ruby.
|
|
35
|
+
#
|
|
36
|
+
# M19 (docs/audits/2026-08-10-main-bug-audit.md,
|
|
37
|
+
# docs/audits/2026-08-11-bug-triage.md) — this used to diverge from
|
|
38
|
+
# `Runtime::ReadModelInterpreter#project` (the in-process path) on
|
|
39
|
+
# two counts, both fixed here to agree with it:
|
|
40
|
+
#
|
|
41
|
+
# MISSING ROOT: the in-process path's own `fetch` refuses with
|
|
42
|
+
# `NotFound` when the reference argument names no record —
|
|
43
|
+
# `query_read_model` used to answer a silent `{root: nil, ...}`
|
|
44
|
+
# instead, the one path a caller could dispatch a read model
|
|
45
|
+
# against a record that never existed and get back something that
|
|
46
|
+
# LOOKS like an empty report rather than the refusal every other
|
|
47
|
+
# path gives.
|
|
48
|
+
#
|
|
49
|
+
# CHAINED-INCLUDE JOIN SCOPE: a non-root head was always matched
|
|
50
|
+
# against the ROOT's own id, regardless of what it actually
|
|
51
|
+
# references — correct for a head that references the root
|
|
52
|
+
# directly, silently EMPTY for one that references another
|
|
53
|
+
# included head instead (`Leaf` -> `Mid` -> `Root`, `Leaf` itself
|
|
54
|
+
# has no attribute referencing `Root` at all, so `references`
|
|
55
|
+
# was always `[]`). The in-process path's own root-first fix
|
|
56
|
+
# (`ReadModelInterpreter#project`'s "ROOT FIRST, ALWAYS" comment)
|
|
57
|
+
# already matches a head against ANY already-projected source, not
|
|
58
|
+
# only the root — `select_related` now does the same: each head is
|
|
59
|
+
# matched against every source resolved so far (root first, then
|
|
60
|
+
# declared order — the same one-level-of-declaration-order
|
|
61
|
+
# dependency the in-process path itself still has, documented
|
|
62
|
+
# there as L2, not a gap introduced here).
|
|
32
63
|
def query_read_model(_domain, model, args, bluebook = nil)
|
|
33
64
|
raise ArgumentError, "projection query needs its domain bluebook" unless bluebook
|
|
34
65
|
|
|
@@ -37,15 +68,27 @@ module Hecks
|
|
|
37
68
|
# declared as a path and followed.
|
|
38
69
|
reference_id = args.fetch(model.reference_name).to_s
|
|
39
70
|
eligible = model.filtered_head_name
|
|
71
|
+
|
|
72
|
+
# ROOT FIRST, ALWAYS — see this method's own header. Mirrors
|
|
73
|
+
# `ReadModelInterpreter#project`'s identical partition, for the
|
|
74
|
+
# identical reason: a later head's own join has to be able to
|
|
75
|
+
# match against a root (or another head) already resolved.
|
|
76
|
+
root_heads, other_heads = model.aggregate_heads.partition { |head| head[:aggregate] == model.reference_target }
|
|
77
|
+
projected = []
|
|
40
78
|
reports = {}
|
|
41
|
-
|
|
79
|
+
(root_heads + other_heads).each do |head|
|
|
42
80
|
aggregate = bluebook.aggregate(head[:aggregate])
|
|
43
81
|
rows = if head[:aggregate] == model.reference_target
|
|
44
|
-
[select_projected(aggregate, reference_id)
|
|
82
|
+
[select_projected(aggregate, reference_id) ||
|
|
83
|
+
raise(Runtime::NotFound,
|
|
84
|
+
Runtime::RefusalWording.render("NotFound", "read_model_reference_missing",
|
|
85
|
+
aggregate: head[:aggregate],
|
|
86
|
+
offered: Hecks::Rendering.describe(reference_id)))]
|
|
45
87
|
else
|
|
46
|
-
select_related(aggregate,
|
|
88
|
+
select_related(aggregate, projected)
|
|
47
89
|
end
|
|
48
90
|
rows = Ports::Query::InMemory.execute(rows, model, args) if head[:as] == eligible
|
|
91
|
+
projected << { aggregate: head[:aggregate], rows: rows }
|
|
49
92
|
reports[head[:as]] = if head[:many]
|
|
50
93
|
rows.map { |row| Runtime::Value.materialize(row.to_h) }
|
|
51
94
|
else
|
|
@@ -62,18 +105,32 @@ module Hecks
|
|
|
62
105
|
row && projected_instance(aggregate, row)
|
|
63
106
|
end
|
|
64
107
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
108
|
+
# Matched against EVERY source already projected (root first, then
|
|
109
|
+
# declared order — see this class's own `query_read_model` header),
|
|
110
|
+
# not only the root — a head whose own reference points at another
|
|
111
|
+
# included head rather than the root directly used to match nothing
|
|
112
|
+
# at all, since its reference attribute was compared against a
|
|
113
|
+
# target (the root) it never names.
|
|
114
|
+
def select_related(aggregate, projected)
|
|
115
|
+
matches = projected.flat_map do |source|
|
|
116
|
+
references = aggregate.attributes.select do |attribute|
|
|
117
|
+
attribute.reference? && attribute.type.target_name == source[:aggregate].to_s
|
|
118
|
+
end
|
|
119
|
+
next [] if references.empty?
|
|
120
|
+
|
|
121
|
+
ids = source[:rows].map { |row| row.id.to_s }
|
|
122
|
+
next [] if ids.empty?
|
|
123
|
+
|
|
124
|
+
references.product(ids)
|
|
68
125
|
end
|
|
69
|
-
return [] if
|
|
126
|
+
return [] if matches.empty?
|
|
70
127
|
|
|
71
128
|
# A REFERENCE COLUMN HOLDS THE ID, so it compares as itself. The
|
|
72
129
|
# `json_extract(col,'$.value') = ? OR col = ?` this replaced was
|
|
73
130
|
# reading both shapes because both existed — one written by the
|
|
74
131
|
# command path, one by older journals. There is one shape now.
|
|
75
|
-
clauses =
|
|
76
|
-
bind =
|
|
132
|
+
clauses = matches.map { |attribute, _id| "#{quote_ident(attribute.name)} = ?" }
|
|
133
|
+
bind = matches.map { |_attribute, id| id }
|
|
77
134
|
@db.execute("SELECT * FROM #{quote_ident(aggregate.storage_name)} WHERE #{clauses.join(' OR ')} ORDER BY id", bind)
|
|
78
135
|
.map { |row| projected_instance(aggregate, row) }
|
|
79
136
|
end
|
|
@@ -87,6 +144,16 @@ module Hecks
|
|
|
87
144
|
if (lifecycle = aggregate.lifecycle) && !fields.any? { |name, _| name == lifecycle.field }
|
|
88
145
|
fields << [lifecycle.field, nil]
|
|
89
146
|
end
|
|
147
|
+
# `projects` FIELDS (S12, ADR 0025) NEED READING BACK TOO — `project`
|
|
148
|
+
# (above) already writes one into its own column via `persisted_fields`
|
|
149
|
+
# (`Codec#persisted_fields`, this class's own superclass module), but
|
|
150
|
+
# this method built its own independent field list that never
|
|
151
|
+
# consulted it — a column `project` populated correctly, silently
|
|
152
|
+
# dropped on every read back out. Same raw-passthrough treatment as
|
|
153
|
+
# the lifecycle field just above: `attribute: nil` down in the loop.
|
|
154
|
+
aggregate.projected_fields.each do |field|
|
|
155
|
+
fields << [field.name, nil] unless fields.any? { |name, _| name == field.name }
|
|
156
|
+
end
|
|
90
157
|
fields.each_with_object({}) do |(name, attribute), state|
|
|
91
158
|
raw = row[name.to_s]
|
|
92
159
|
state[name] =
|
|
@@ -74,14 +74,26 @@ module Hecks
|
|
|
74
74
|
def create_saga_table!
|
|
75
75
|
@db.execute(<<~SQL)
|
|
76
76
|
CREATE TABLE IF NOT EXISTS hecks_saga_instances (
|
|
77
|
-
domain
|
|
78
|
-
process_manager
|
|
79
|
-
correlation
|
|
80
|
-
state
|
|
81
|
-
memory
|
|
77
|
+
domain TEXT NOT NULL,
|
|
78
|
+
process_manager TEXT NOT NULL,
|
|
79
|
+
correlation TEXT NOT NULL,
|
|
80
|
+
state TEXT NOT NULL,
|
|
81
|
+
memory TEXT NOT NULL,
|
|
82
|
+
completed_compensations TEXT NOT NULL DEFAULT '[]',
|
|
82
83
|
PRIMARY KEY (domain, process_manager, correlation)
|
|
83
84
|
)
|
|
84
85
|
SQL
|
|
86
|
+
# `CREATE TABLE IF NOT EXISTS` above is a no-op against a table
|
|
87
|
+
# this same domain already created before this column existed
|
|
88
|
+
# — the same reason Postgres's own `create_saga_table!` needs
|
|
89
|
+
# its own `ADD COLUMN IF NOT EXISTS`. SQLite/D1's own `ALTER
|
|
90
|
+
# TABLE ... ADD COLUMN` has no `IF NOT EXISTS` guard on every
|
|
91
|
+
# version this adapter supports, so a duplicate-column error
|
|
92
|
+
# is caught and treated as "already there" rather than relied
|
|
93
|
+
# on to never happen.
|
|
94
|
+
@db.execute("ALTER TABLE hecks_saga_instances ADD COLUMN completed_compensations TEXT NOT NULL DEFAULT '[]'")
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
raise unless e.message.include?("duplicate column name")
|
|
85
97
|
end
|
|
86
98
|
|
|
87
99
|
def sql_type(attr)
|
|
@@ -40,7 +40,15 @@ module Hecks
|
|
|
40
40
|
# (§2/§4) — falls back to the aggregate's own name for a
|
|
41
41
|
# directly-instantiated adapter (specs), same fallback shape
|
|
42
42
|
# Postgres's own @domain already uses.
|
|
43
|
-
@domain = (
|
|
43
|
+
@domain = (
|
|
44
|
+
if settings.key?(:domain)
|
|
45
|
+
settings[:domain]
|
|
46
|
+
elsif settings.key?("domain")
|
|
47
|
+
settings["domain"]
|
|
48
|
+
else
|
|
49
|
+
aggregate.name
|
|
50
|
+
end
|
|
51
|
+
).to_s
|
|
44
52
|
|
|
45
53
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
46
54
|
@db = SQLite3::Database.new(@path)
|
|
@@ -89,7 +97,12 @@ module Hecks
|
|
|
89
97
|
def append(entry)
|
|
90
98
|
@db.execute(
|
|
91
99
|
"INSERT INTO #{quoted_entry_table} (aggregate_id, operation, state, mirrors) VALUES (?, ?, ?, ?)",
|
|
92
|
-
|
|
100
|
+
# `mirrors` (unlike `state`) is a NULLABLE column — an absent
|
|
101
|
+
# mirrors hash must bind a real SQL NULL, not the four-character
|
|
102
|
+
# JSON text `"null"` (`JSON.generate(nil)`), or a future `IS NULL`
|
|
103
|
+
# check against it would never match. Same guard `postgres_era.rb`
|
|
104
|
+
# already uses for its own journal's `mirrors` column.
|
|
105
|
+
[entry.id, entry.operation, JSON.generate(entry.state), entry.mirrors && JSON.generate(entry.mirrors)]
|
|
93
106
|
)
|
|
94
107
|
entry
|
|
95
108
|
end
|
|
@@ -189,11 +202,11 @@ module Hecks
|
|
|
189
202
|
# either way; a domain that wants an obviously-named saga store
|
|
190
203
|
# already gets one by sharing `database` across its aggregates,
|
|
191
204
|
# the recommended, common case.
|
|
192
|
-
def save_saga(process_manager:, correlation:, state:, memory:)
|
|
205
|
+
def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
|
|
193
206
|
@db.execute(
|
|
194
|
-
"INSERT OR REPLACE INTO hecks_saga_instances (domain, process_manager, correlation, state, memory) " \
|
|
195
|
-
"VALUES (?, ?, ?, ?, ?)",
|
|
196
|
-
[@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory)]
|
|
207
|
+
"INSERT OR REPLACE INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, completed_compensations) " \
|
|
208
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
209
|
+
[@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory), JSON.generate(completed_compensations)]
|
|
197
210
|
)
|
|
198
211
|
end
|
|
199
212
|
|
|
@@ -208,11 +221,12 @@ module Hecks
|
|
|
208
221
|
return enum_for(:each_saga) unless block_given?
|
|
209
222
|
|
|
210
223
|
@db.execute(
|
|
211
|
-
"SELECT process_manager, correlation, state, memory FROM hecks_saga_instances WHERE domain = ?",
|
|
224
|
+
"SELECT process_manager, correlation, state, memory, completed_compensations FROM hecks_saga_instances WHERE domain = ?",
|
|
212
225
|
[@domain]
|
|
213
226
|
).each do |row|
|
|
214
227
|
yield row["process_manager"], row["correlation"], row["state"],
|
|
215
|
-
JSON.parse(row["memory"], symbolize_names: true)
|
|
228
|
+
JSON.parse(row["memory"], symbolize_names: true),
|
|
229
|
+
JSON.parse(row["completed_compensations"] || "[]", symbolize_names: true)
|
|
216
230
|
end
|
|
217
231
|
end
|
|
218
232
|
|
|
@@ -269,7 +283,14 @@ module Hecks
|
|
|
269
283
|
def quoted_entry_table = quote_ident(entry_table)
|
|
270
284
|
|
|
271
285
|
def resolve_path(settings, root)
|
|
272
|
-
declared =
|
|
286
|
+
declared =
|
|
287
|
+
if settings.key?(:database)
|
|
288
|
+
settings[:database]
|
|
289
|
+
elsif settings.key?("database")
|
|
290
|
+
settings["database"]
|
|
291
|
+
else
|
|
292
|
+
"data/#{table}.db"
|
|
293
|
+
end
|
|
273
294
|
return declared if declared.start_with?("/")
|
|
274
295
|
|
|
275
296
|
File.join(root || Dir.pwd, declared)
|
|
@@ -11,7 +11,25 @@ end
|
|
|
11
11
|
require_relative "driven/memory"
|
|
12
12
|
require_relative "driven/sqlite"
|
|
13
13
|
require_relative "driven/postgres"
|
|
14
|
-
require_relative
|
|
14
|
+
# `PostgresEra` is NOT `require_relative`d here — ADR 0033 moved it, and
|
|
15
|
+
# the rest of the era/lineage/translation subsystem, behind a loadable
|
|
16
|
+
# persistence plugin (`hecks/ports/persistence/plugins/era`) rather than
|
|
17
|
+
# requiring every app to carry it whether or not anything ever binds
|
|
18
|
+
# `persisted_by "PostgresEra"`. `Module#autoload` is the one-line seam
|
|
19
|
+
# that makes that genuinely load-nothing-until-asked instead of pushing
|
|
20
|
+
# an explicit `require` onto every one of the ~15 generic `bin/*` tools
|
|
21
|
+
# that boot an arbitrary checked-in domain (several of which — pizzas,
|
|
22
|
+
# compliance, chess, roster — bind PostgresEra): the FIRST real
|
|
23
|
+
# `Adapters.const_get("PostgresEra")` (`registry/verification.rb`'s own
|
|
24
|
+
# adapter resolution, unchanged) transparently `require`s the plugin
|
|
25
|
+
# entry point, which defines the constant AND calls `register_plugin`
|
|
26
|
+
# as its own side effect (`plugins/era.rb`'s last line) — the exact
|
|
27
|
+
# same effect an explicit `require "hecks/ports/persistence/plugins/
|
|
28
|
+
# era"` has, just deferred to the moment something is actually asked
|
|
29
|
+
# for `PostgresEra` rather than sprinkled across every caller that
|
|
30
|
+
# might. A domain that never binds it never triggers the autoload, so
|
|
31
|
+
# never loads a line of era code — unchanged from ADR 0033's own goal.
|
|
32
|
+
Hecks::Adapters.autoload(:PostgresEra, "hecks/ports/persistence/plugins/era")
|
|
15
33
|
require_relative "driven/lambda"
|
|
16
34
|
require_relative "driven/heki"
|
|
17
35
|
require_relative "driven/prism"
|
data/lib/hecks/behaviors/dsl.rb
CHANGED
|
@@ -41,10 +41,39 @@ module Hecks
|
|
|
41
41
|
"say which command or query this example exercises"
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
validate_expect!
|
|
45
|
+
|
|
44
46
|
TestCase.new(description: @description, tests_command: @tests_command,
|
|
45
47
|
on_aggregate: @on_aggregate, kind: @kind,
|
|
46
48
|
setups: @setups, input: @input, expect: @expect)
|
|
47
49
|
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Closes the silent-pass paths a free-form `expect(**kwargs)` merge
|
|
54
|
+
# otherwise leaves open: a test with no `expect` at all asserts
|
|
55
|
+
# nothing and passes whenever dispatch doesn't raise; `count:` on a
|
|
56
|
+
# command and `emits:` on a query are each read by neither runner
|
|
57
|
+
# (Expectations#run_command/#run_query), so they're accepted here
|
|
58
|
+
# and then silently ignored at run time. Checking both at BUILD
|
|
59
|
+
# time, not in the runners, makes them errors on the file that
|
|
60
|
+
# wrote them rather than green checks nobody questions.
|
|
61
|
+
def validate_expect!
|
|
62
|
+
if @expect.empty?
|
|
63
|
+
raise Malformed, "test #{@description.inspect} has no `expect` — say what this " \
|
|
64
|
+
"example proves (ok:, refused:, emits:, count:, or a field name)"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
if @kind == :command && @expect.key?(:count)
|
|
68
|
+
raise Malformed, "test #{@description.inspect}: `expect count:` only applies to " \
|
|
69
|
+
"queries — a command has no row count to check"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if @kind == :query && @expect.key?(:emits)
|
|
73
|
+
raise Malformed, "test #{@description.inspect}: `expect emits:` only applies to " \
|
|
74
|
+
"commands — a query never dispatches, so it never emits"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
48
77
|
end
|
|
49
78
|
|
|
50
79
|
class BehaviorsBuilder
|
|
@@ -3,6 +3,7 @@ require_relative "../runtime/loader"
|
|
|
3
3
|
require_relative "../runtime/errors"
|
|
4
4
|
require_relative "../runtime/value"
|
|
5
5
|
require_relative "../runtime/reaction_invocation"
|
|
6
|
+
require_relative "../ports/persistence/binding_policy"
|
|
6
7
|
|
|
7
8
|
# Hecks::Behaviors::Expectations
|
|
8
9
|
#
|
|
@@ -83,7 +84,40 @@ module Hecks
|
|
|
83
84
|
key = files.map { |file| [file, File.exist?(file) ? File.mtime(file).to_f : nil] }
|
|
84
85
|
|
|
85
86
|
RUNTIMES_LOCK.synchronize do
|
|
86
|
-
RUNTIMES[key] ||=
|
|
87
|
+
RUNTIMES[key] ||= boot_and_guard(files)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# `reset_runtime_state!` only drops repository OBJECTS between
|
|
92
|
+
# tests (registry.rb) — sufficient isolation for `Memory`, whose
|
|
93
|
+
# `@records` is a plain per-instance ivar, so a fresh object really
|
|
94
|
+
# is a fresh store. Against anything else (Sqlite, Postgres) the
|
|
95
|
+
# rows themselves stay put: tests leak into each other, and a
|
|
96
|
+
# suite booted against a domain's REAL hecksagon writes to a real
|
|
97
|
+
# database. Refusing that wiring here, at boot, is the same shape
|
|
98
|
+
# of guard `BindingPolicy` already applies to a missing bind — the
|
|
99
|
+
# project's identity is refusing bad wiring up front, not
|
|
100
|
+
# discovering it mid-suite.
|
|
101
|
+
def boot_and_guard(files)
|
|
102
|
+
runtime = Hecks::Runtime::Loader.boot_files(files, install_facade: false)
|
|
103
|
+
guard_memory_only!(runtime)
|
|
104
|
+
runtime
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def guard_memory_only!(runtime)
|
|
108
|
+
runtime.registry.bluebooks.each_value do |bluebook|
|
|
109
|
+
bluebook.aggregates.each do |aggregate|
|
|
110
|
+
bind = Ports::Persistence::BindingPolicy.resolve(runtime.registry, bluebook.name, aggregate)
|
|
111
|
+
next if bind.adapter == Ports::Persistence::DEFAULT_ADAPTER
|
|
112
|
+
|
|
113
|
+
raise Malformed,
|
|
114
|
+
"#{bluebook.name}::#{aggregate.hecks_name} is persisted_by " \
|
|
115
|
+
"#{bind.adapter.inspect}, not #{Ports::Persistence::DEFAULT_ADAPTER.inspect} — " \
|
|
116
|
+
"a behaviors suite's `loads` must resolve every aggregate to an in-memory " \
|
|
117
|
+
"binding, or tests leak state into each other and write to a real database. " \
|
|
118
|
+
"Load a Memory-bound sibling hecksagon instead of the domain's real one — see " \
|
|
119
|
+
"examples/pizzas/bluebook/pizzas.behaviors's own `loads` comment for the pattern."
|
|
120
|
+
end
|
|
87
121
|
end
|
|
88
122
|
end
|
|
89
123
|
|
|
@@ -182,7 +216,33 @@ module Hecks
|
|
|
182
216
|
return fail_result(test, "expected count: #{expected}, got #{count}") if count != expected
|
|
183
217
|
end
|
|
184
218
|
|
|
185
|
-
check_ok(test) || pass_result(test)
|
|
219
|
+
return check_ok(test) || pass_result(test) unless field_expectations?(test)
|
|
220
|
+
|
|
221
|
+
row = query_row(test, rows)
|
|
222
|
+
return row if row.is_a?(Result)
|
|
223
|
+
|
|
224
|
+
check_ok(test) || check_fields(test, row) || pass_result(test)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def field_expectations?(test)
|
|
228
|
+
test.expect.keys.any? { |key| !SPECIAL_KEYS.include?(key) }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# A field expectation on a query names one row's shape, so it only
|
|
232
|
+
# makes sense once the query has settled on exactly one — the same
|
|
233
|
+
# reason `expect status: "sold"` on a multi-row answer would be
|
|
234
|
+
# ambiguous about which row it's describing. `check_fields` itself
|
|
235
|
+
# stays row-shaped (it already is, for `run_command`'s settled
|
|
236
|
+
# state); this picks which row it reads.
|
|
237
|
+
def query_row(test, rows)
|
|
238
|
+
return rows unless rows.is_a?(Array)
|
|
239
|
+
|
|
240
|
+
case rows.size
|
|
241
|
+
when 1 then rows.first
|
|
242
|
+
when 0 then fail_result(test, "expect names a field, but the query returned no rows")
|
|
243
|
+
else fail_result(test, "expect names a field, but the query returned #{rows.size} rows — " \
|
|
244
|
+
"narrow it with `input`/`expect count: 1` first")
|
|
245
|
+
end
|
|
186
246
|
end
|
|
187
247
|
|
|
188
248
|
def check_ok(test)
|
|
@@ -255,20 +255,49 @@ module Hecks
|
|
|
255
255
|
),
|
|
256
256
|
|
|
257
257
|
# S17, ADR 0026 — Dispatch is a genuine entity now, nested under
|
|
258
|
-
# Handler (`entity "Dispatch"`,
|
|
259
|
-
# deep, "no life outside its Handler" (the ADR's own
|
|
260
|
-
# `command_name`
|
|
261
|
-
#
|
|
262
|
-
#
|
|
258
|
+
# Handler (`entity "Dispatch"`, process_manager.bluebook) — two
|
|
259
|
+
# levels deep, "no life outside its Handler" (the ADR's own
|
|
260
|
+
# words). `command_name` ALONE used to be Dispatch's own
|
|
261
|
+
# identity, until items #151/#152 (`process_manager.bluebook`'s
|
|
262
|
+
# own `DispatchPosition` comment) found it collided the instant a
|
|
263
|
+
# real handler fanned the same command out more than once —
|
|
264
|
+
# `position` now joins it, walk-minted the same way every other
|
|
265
|
+
# category's own `position` is (`derived: { position: :walk }`,
|
|
266
|
+
# the same entry ProcessManager's own contract carries above) —
|
|
267
|
+
# never a stored field on `DispatchSpec` itself, exactly like
|
|
268
|
+
# `handler` before it.
|
|
269
|
+
# `compensates_command_name`/`compensates_with_spec` — FOLDED,
|
|
270
|
+
# the SAME kind `Lifecycle`'s own `state_field`/`default` claim
|
|
271
|
+
# (one IR OBJECT, `DispatchSpec#compensates`, feeding two
|
|
272
|
+
# separate language fields): `Readings#field_value`'s own
|
|
273
|
+
# `contract.folded` branch reads
|
|
274
|
+
# `node.compensates.to_h[:command_name]` / `[:with_spec]` for
|
|
275
|
+
# the JUDGE'S offering side, nil-safe when there is no
|
|
276
|
+
# compensation at all (`through`'s own `return nil unless
|
|
277
|
+
# held`). `compensates_with_spec` ALSO needs its own row shaper
|
|
278
|
+
# (`compensates_with_spec_rows`, readings.rb) for the JUDGE'S
|
|
279
|
+
# list-offering side — the with_spec pairs still need one
|
|
280
|
+
# "BindCompensation" append per pair, the same reason
|
|
281
|
+
# `with_spec` itself needs `with_spec_rows`, one level deeper.
|
|
282
|
+
# `Reconstruction#dispatch` reads the two flat fields back off
|
|
283
|
+
# the row and assembles the nested `DispatchSpec` by hand — a
|
|
284
|
+
# nested OBJECT is not one of the shapes `declaration()`'s own
|
|
285
|
+
# generic per-field hash-build can produce, the identical reason
|
|
286
|
+
# `handler`/`process_manager` pass a `:children` list through
|
|
287
|
+
# `extra:` instead.
|
|
263
288
|
"Dispatch" => Contract.new(
|
|
264
289
|
holder: DispatchSpec, make: :new,
|
|
265
290
|
fields: {
|
|
266
291
|
command_name: [:command_name, :plain],
|
|
267
292
|
with_spec: [:with_spec, :bindings]
|
|
268
293
|
},
|
|
269
|
-
rows: { with_spec: :with_spec_rows },
|
|
294
|
+
rows: { with_spec: :with_spec_rows, compensates_with_spec: :compensates_with_spec_rows },
|
|
270
295
|
reads: { with_spec: [:from, :with_spec] },
|
|
271
|
-
derived: {
|
|
296
|
+
derived: {
|
|
297
|
+
position: :walk,
|
|
298
|
+
compensates_command_name: [:folded, :compensates, :command_name],
|
|
299
|
+
compensates_with_spec: [:folded, :compensates, :with_spec]
|
|
300
|
+
}
|
|
272
301
|
),
|
|
273
302
|
|
|
274
303
|
# S14, ADR 0026 — Syntax/Keyword/Argument are never built via
|
|
@@ -126,9 +126,10 @@ module Hecks
|
|
|
126
126
|
target = change[:target].to_sym
|
|
127
127
|
op = change[:op].to_sym
|
|
128
128
|
|
|
129
|
-
# `:delegate` (CommandBuilder#delegates_to's
|
|
130
|
-
# the SAME multi-binding
|
|
131
|
-
|
|
129
|
+
# `:delegate`/`:corrects` (CommandBuilder#delegates_to's and
|
|
130
|
+
# #corrects_impl's own comments) ride the SAME multi-binding
|
|
131
|
+
# shape `:append` does.
|
|
132
|
+
return Mutation.new(target: target, op: op, source: appended(change[:fields])) if [:append, :delegate, :corrects].include?(op)
|
|
132
133
|
|
|
133
134
|
Mutation.new(target: target, op: op, source: classified(change[:source]))
|
|
134
135
|
end
|
|
@@ -71,7 +71,20 @@ module Hecks
|
|
|
71
71
|
|
|
72
72
|
def handler(row)
|
|
73
73
|
Build.call("Handler", row,
|
|
74
|
-
dispatches: Array(row[:dispatches]).map { |leg|
|
|
74
|
+
dispatches: Array(row[:dispatches]).map { |leg| dispatch(leg) })
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# `compensates` — a PLAIN HASH on the declaration (`Reconstruction#
|
|
78
|
+
# dispatch`'s own comment for why), built into the real
|
|
79
|
+
# `DispatchSpec` its own field actually is by recursing through
|
|
80
|
+
# THIS SAME method, one level in — the identical move `handler`
|
|
81
|
+
# itself takes into `dispatches`, one level up. `nil` when there
|
|
82
|
+
# is nothing to compensate; `Build.call` never sees a
|
|
83
|
+
# `compensates:` key it does not know how to read either way, the
|
|
84
|
+
# same reason `compensates` never joined `Contract#fields` at all.
|
|
85
|
+
def dispatch(row)
|
|
86
|
+
compensates = row[:compensates] && dispatch(row[:compensates])
|
|
87
|
+
Build.call("Dispatch", row, compensates: compensates)
|
|
75
88
|
end
|
|
76
89
|
end
|
|
77
90
|
end
|
|
@@ -37,7 +37,24 @@ module Hecks
|
|
|
37
37
|
return nil if matches.empty?
|
|
38
38
|
return matches.first unless current_state
|
|
39
39
|
|
|
40
|
-
matches.
|
|
40
|
+
# NOT `|| matches.first` — that used to silently hand back an
|
|
41
|
+
# ARBITRARY declared transition for `command` whenever none of
|
|
42
|
+
# them actually admitted `current_state`, picking a `target`
|
|
43
|
+
# that command dispatch would in fact have refused (that
|
|
44
|
+
# refusal is `CommandRules::Admissibility#admissible_transition`'s
|
|
45
|
+
# own job, which raises `LifecycleRefused` for exactly this
|
|
46
|
+
# case rather than guessing — this module has no state subject
|
|
47
|
+
# to build that refusal message from, only the two bare
|
|
48
|
+
# values callers passed in). Zero production callers reach
|
|
49
|
+
# this branch today (both real dispatch paths that check a
|
|
50
|
+
# transition go through `admissible_transition`, not
|
|
51
|
+
# `target_for`/`match_transition`), so this raise is a
|
|
52
|
+
# backstop, not a live behaviour change — same shape as
|
|
53
|
+
# `CommandRules::Arithmetic#sign_of`'s own fix.
|
|
54
|
+
matches.find { |t| applies_from?(t, current_state) } ||
|
|
55
|
+
raise(Runtime::WiringError,
|
|
56
|
+
"no transition for #{command.inspect} admits state #{current_state.inspect} " \
|
|
57
|
+
"— add a from: covering it, or check admissibility before calling #target_for")
|
|
41
58
|
end
|
|
42
59
|
|
|
43
60
|
def applies_from?(transition, current)
|
|
@@ -27,8 +27,21 @@ module Hecks
|
|
|
27
27
|
leg = handler_for(Bluebook::ProcessManager::REFUSED)
|
|
28
28
|
return nil unless leg
|
|
29
29
|
|
|
30
|
+
# `compensations` — a STATIC PREVIEW, declaration order, not
|
|
31
|
+
# one instance's own runtime history (which legs a given
|
|
32
|
+
# instance actually completed is per-instance state,
|
|
33
|
+
# `SagaInterpreter`'s own `completed_compensations`, not a
|
|
34
|
+
# fact `Saga` — a pure declaration reading — could ever hold).
|
|
35
|
+
# Every `compensates` ANY handler's own dispatch declares,
|
|
36
|
+
# forward declaration order, THEN whatever this leg's own
|
|
37
|
+
# hand-written body still lists — coexistence, not replacement
|
|
38
|
+
# (`ProcessManagerBuilder::HandlerBuilder#dispatch_impl`'s own
|
|
39
|
+
# comment): a saga can derive some of its compensation and
|
|
40
|
+
# still hand-write the rest for what isn't expressible as
|
|
41
|
+
# "undo command X".
|
|
42
|
+
derived = handlers.flat_map { |handler| handler.dispatches.filter_map(&:compensates) }
|
|
30
43
|
Bluebook::Saga.new(trigger: Bluebook::ProcessManager::REFUSED, from_state: leg.from_state,
|
|
31
|
-
to_state: leg.to_state,
|
|
44
|
+
to_state: leg.to_state, compensations: derived + leg.dispatches)
|
|
32
45
|
end
|
|
33
46
|
|
|
34
47
|
def saga? = !saga.nil?
|
|
@@ -30,17 +30,27 @@ module Hecks
|
|
|
30
30
|
IR_VERSION = 1
|
|
31
31
|
|
|
32
32
|
emits_ir(
|
|
33
|
-
ir_version:
|
|
34
|
-
name:
|
|
35
|
-
version:
|
|
36
|
-
vision:
|
|
37
|
-
classification:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
33
|
+
ir_version: -> { IR_VERSION },
|
|
34
|
+
name: :name,
|
|
35
|
+
version: :version,
|
|
36
|
+
vision: :vision,
|
|
37
|
+
classification: :classification,
|
|
38
|
+
# M10 — a domain rename (`formerly_known_as "OldName"`) drives a
|
|
39
|
+
# real Postgres schema rename at boot (EraResolver reads
|
|
40
|
+
# `bluebook.formerly_known_as` off the live Ruby object) and the
|
|
41
|
+
# meta-validator's cache key is `SHA256(JSON(bluebook.to_h))` — so
|
|
42
|
+
# a fact this method didn't spell was a fact two chapters
|
|
43
|
+
# differing ONLY by their old name could hash identically on,
|
|
44
|
+
# same as read-model filters before them. Spelled here for the
|
|
45
|
+
# same reason `version`/`vision` are: a plain field, present
|
|
46
|
+
# (possibly nil) rather than silently absent.
|
|
47
|
+
formerly_known_as: :formerly_known_as,
|
|
48
|
+
aggregates: many(:aggregates),
|
|
49
|
+
read_models: many(:read_models),
|
|
50
|
+
policies: many(:policies),
|
|
51
|
+
process_managers: many(:process_managers),
|
|
52
|
+
attaches_to: :attaches_to,
|
|
53
|
+
canonical_form: -> { Expression::CanonicalForm.table }
|
|
44
54
|
)
|
|
45
55
|
|
|
46
56
|
attr_reader :name, :version, :vision, :aggregates, :policies, :process_managers,
|
|
@@ -39,7 +39,7 @@ module Hecks
|
|
|
39
39
|
# the tail, which is why a construct with a variable shape needs no
|
|
40
40
|
# new mixin API.
|
|
41
41
|
def to_h
|
|
42
|
-
return super.merge(fields: appended_fields) if [:append, :delegate].include?(op)
|
|
42
|
+
return super.merge(fields: appended_fields) if [:append, :delegate, :corrects].include?(op)
|
|
43
43
|
|
|
44
44
|
super.merge(source: classified_source)
|
|
45
45
|
end
|