hecks 3.13.0 → 4.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/examples/pizzas/adapters/fake_processor.rb +34 -0
- data/examples/pizzas/adapters/payment_signature.rb +66 -0
- data/examples/pizzas/adapters/payment_webhook.rb +87 -0
- data/examples/pizzas/bluebook/pizzas.behaviors +33 -0
- data/examples/pizzas/bluebook/pizzas.bluebook +4 -4
- data/examples/pizzas/bluebook/pizzas.hecksagon +5 -3
- data/examples/pizzas/config.ru +22 -0
- data/lib/hecks/adapters/driven/prism.rb +0 -2
- data/lib/hecks/adapters/driven.rb +0 -2
- data/lib/hecks/adapters/driving/json.rb +1 -7
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +15 -5
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -2
- data/lib/hecks/bluebook/dsl/derivation_words.rb +58 -0
- data/lib/hecks/bluebook/dsl/translation_builder.rb +6 -37
- data/lib/hecks/bluebook/expression/ast_json.rb +0 -9
- data/lib/hecks/bluebook/hecksagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/translation_judge/rules.rb +7 -10
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +4 -8
- data/lib/hecks/bluebook/meta_validator.rb +0 -11
- data/lib/hecks/bluebook/named_pattern.rb +33 -0
- data/lib/hecks/bluebook/translation.rb +18 -10
- data/lib/hecks/bluebook/translation_expression/evaluator.rb +63 -0
- data/lib/hecks/bluebook/translation_expression/parser.rb +127 -0
- data/lib/hecks/bluebook/translation_expression.rb +39 -0
- data/lib/hecks/bluebook/translation_plan.rb +70 -0
- data/lib/hecks/bluebook.rb +3 -0
- data/lib/hecks/chapters.rb +3 -3
- data/lib/hecks/cli/domain_stub.rb +1 -1
- data/lib/hecks/cli/interview_aggregate.rb +4 -4
- data/lib/hecks/composer/adapters/claude_composer.adapter +3 -0
- data/lib/hecks/composer/adapters/claude_composer.rb +83 -0
- data/lib/hecks/composer/adapters/fake_agent.adapter +3 -0
- data/lib/hecks/composer/adapters/fake_agent.rb +55 -0
- data/lib/hecks/composer/adapters/jev.adapter +3 -0
- data/lib/hecks/composer/adapters/jev.rb +66 -0
- data/lib/hecks/composer/agent.rb +113 -0
- data/lib/hecks/composer/assistant.rb +110 -0
- data/lib/hecks/composer/behavior_run.rb +53 -0
- data/lib/hecks/composer/bluebook/composer.bluebook +178 -0
- data/lib/hecks/composer/bluebook/composer.hecksagon +16 -0
- data/lib/hecks/composer/board.rb +65 -0
- data/lib/hecks/composer/board_edit/command_text.rb +39 -0
- data/lib/hecks/composer/board_edit/source.rb +84 -0
- data/lib/hecks/composer/board_edit.rb +144 -0
- data/lib/hecks/composer/check.rb +89 -0
- data/lib/hecks/composer/domains.rb +81 -0
- data/lib/hecks/composer/jobs.rb +134 -0
- data/lib/hecks/composer/layout.rb +37 -0
- data/lib/hecks/composer/live.rb +150 -0
- data/lib/hecks/composer/outline.rb +48 -0
- data/lib/hecks/composer/page.html +989 -0
- data/lib/hecks/composer/panels.rb +142 -0
- data/lib/hecks/composer/parse.rb +83 -0
- data/lib/hecks/composer/plain.rb +28 -0
- data/lib/hecks/composer/ports/composer_agent.port +7 -0
- data/lib/hecks/composer/session.rb +141 -0
- data/lib/hecks/composer/toolbox.rb +166 -0
- data/lib/hecks/composer/web.rb +156 -0
- data/lib/hecks/composer/workbench.rb +51 -0
- data/lib/hecks/composer/workspace.rb +81 -0
- data/lib/hecks/composer.rb +17 -0
- data/lib/hecks/corpus/adapters.rb +22 -0
- data/lib/hecks/corpus/rust.rb +1 -1
- data/lib/hecks/corpus/worlds.rb +19 -0
- data/lib/hecks/corpus.rb +5 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -34
- data/lib/hecks/forms/app/commands.rb +2 -3
- data/lib/hecks/forms/app/queries.rb +2 -3
- data/lib/hecks/forms/app/records.rb +17 -5
- data/lib/hecks/forms/command_form_renderer.rb +42 -16
- data/lib/hecks/forms/field_controls.rb +2 -2
- data/lib/hecks/forms/field_renderer.rb +1 -1
- data/lib/hecks/forms/field_shape.rb +36 -17
- data/lib/hecks/forms/index_renderer.rb +2 -1
- data/lib/hecks/forms/page.rb +1 -1
- data/lib/hecks/forms/params.rb +0 -13
- data/lib/hecks/forms/query_form_renderer.rb +7 -6
- data/lib/hecks/forms/record_renderer.rb +11 -7
- data/lib/hecks/forms/rule_words.rb +100 -0
- data/lib/hecks/forms/value_object_shape.rb +2 -1
- data/lib/hecks/framework/bluebook/change.bluebook +2 -2
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -3
- data/lib/hecks/fuzzing/domain_generator/block_rendering.rb +1 -1
- data/lib/hecks/fuzzing/isolated_boot/worlds.rb +3 -6
- data/lib/hecks/fuzzing/replay/filters.rb +2 -6
- data/lib/hecks/gate/stages.yml +25 -0
- data/lib/hecks/grammar/evolve/seeds.rb +0 -6
- data/lib/hecks/grammar/evolve.rb +0 -3
- data/lib/hecks/grammar/translation.bluebook +1 -1
- data/lib/hecks/hecks/adapters/codebase/composing.rb +56 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
- data/lib/hecks/hecks/adapters/journal_store/changes.rb +0 -26
- data/lib/hecks/hecks/adapters/journal_store/examination.rb +2 -23
- data/lib/hecks/hecks/adapters/journal_store/readings.rb +3 -11
- data/lib/hecks/hecks/adapters/journal_store.rb +0 -11
- data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +4 -11
- data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +0 -9
- data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +0 -4
- data/lib/hecks/hecks/codebase.behaviors +6 -0
- data/lib/hecks/hecks/codebase.bluebook +58 -37
- data/lib/hecks/hecks/composer.behaviors +68 -0
- data/lib/hecks/hecks/context_map.hecksagon +1 -1
- data/lib/hecks/hecks/custodian.behaviors +0 -6
- data/lib/hecks/hecks/custodian.bluebook +15 -92
- data/lib/hecks/hecks/hecks.bluebook +2 -2
- data/lib/hecks/hecks/hecks.hecksagon +2 -2
- data/lib/hecks/hecks/hecks.world +1 -1
- data/lib/hecks/hecks/rehearsal.bluebook +10 -16
- data/lib/hecks/hecks/tooling.bluebook +22 -22
- data/lib/hecks/kernel_capabilities/templates.rb +4 -4
- data/lib/hecks/kernel_capabilities.rb +1 -1
- data/lib/hecks/language/bluebook/aggregate.bluebook +1 -0
- data/lib/hecks/language/bluebook/command.bluebook +1 -0
- data/lib/hecks/language/bluebook/entity.bluebook +1 -0
- data/lib/hecks/language/bluebook/query.bluebook +1 -0
- data/lib/hecks/language/bluebook/shape.bluebook +8 -7
- data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -3
- data/lib/hecks/language/hecksagon/port_operation.bluebook +1 -0
- data/lib/hecks/language/translation/translation_aggregate.bluebook +21 -24
- data/lib/hecks/ports/access_control.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -41
- data/lib/hecks/ports/persistence/plugins/era/lineage/coverage.rb +7 -7
- data/lib/hecks/ports/persistence/plugins/era/lineage/rewrite.rb +25 -2
- data/lib/hecks/ports/persistence/plugins/era/lineage/rules.rb +4 -4
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +17 -25
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/base_sql.rb +0 -12
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/domain_rename.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -22
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +9 -3
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +3 -29
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/refusals.rb +2 -26
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +6 -33
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +2 -4
- data/lib/hecks/ports/persistence/plugins/era/translation/expression_sql.rb +52 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +23 -29
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +3 -6
- data/lib/hecks/ports.rb +0 -1
- data/lib/hecks/projection_files.rb +3 -1
- data/lib/hecks/projections/deploy/templates/lambda/nat_comment_oauth.tmpl +1 -1
- data/lib/hecks/projections/glossary/entries.rb +10 -4
- data/lib/hecks/projections/glossary/groups.rb +42 -0
- data/lib/hecks/projections/glossary/html/page.rb +7 -2
- data/lib/hecks/projections/glossary/html/parser.rb +1 -1
- data/lib/hecks/projections/glossary/html/sections.rb +31 -11
- data/lib/hecks/projections/glossary/html.rb +1 -2
- data/lib/hecks/projections/glossary/markdown.rb +31 -28
- data/lib/hecks/projections/glossary/mermaid.rb +3 -0
- data/lib/hecks/projections/glossary/page.css +143 -153
- data/lib/hecks/projections/glossary/page.js +63 -3
- data/lib/hecks/projections/glossary/sentences/actions.rb +88 -0
- data/lib/hecks/projections/glossary/sentences/paths.rb +77 -0
- data/lib/hecks/projections/glossary/sentences/queries.rb +90 -0
- data/lib/hecks/projections/glossary/sentences/value_objects.rb +32 -5
- data/lib/hecks/projections/glossary/sentences.rb +13 -3
- data/lib/hecks/projections/glossary/slugs.rb +4 -1
- data/lib/hecks/projections/glossary.rb +2 -1
- data/lib/hecks/projections/plain_english.rb +97 -0
- data/lib/hecks/projections/rust_vocabulary/arguments.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary/enum_body.rb +20 -25
- data/lib/hecks/projections/rust_vocabulary/tests.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary.rb +14 -6
- data/lib/hecks/projections/site/cms_editor/color.rb +0 -6
- data/lib/hecks/projections/statements/actions.rb +99 -0
- data/lib/hecks/projections/statements/relationships.rb +86 -0
- data/lib/hecks/projections/statements.rb +15 -24
- data/lib/hecks/projector/exporter/adapters.rb +112 -0
- data/lib/hecks/projector/exporter/translations.rb +18 -35
- data/lib/hecks/projector/exporter/worlds.rb +103 -0
- data/lib/hecks/projector/exporter.rb +5 -1
- data/lib/hecks/quality_control/adapters/agent.rb +6 -7
- data/lib/hecks/quality_control/adapters/agent_profile.rb +16 -114
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +1 -1
- data/lib/hecks/quality_control/quality_control.bluebook +51 -51
- data/lib/hecks/runtime/loader/boot_steps.rb +0 -20
- data/lib/hecks/runtime/outbox.rb +2 -9
- data/lib/hecks/runtime/port_operation_interpreter.rb +1 -9
- data/lib/hecks/runtime/remote_dispatcher.rb +2 -2
- data/lib/hecks/runtime/value.rb +13 -0
- data/lib/hecks/rust_build/project_rust/target_ir.rb +23 -4
- data/lib/hecks/rust_build/wasm.rb +0 -12
- data/lib/hecks/rust_build/write_if_changed.rb +12 -12
- data/lib/hecks/rust_format.rb +77 -0
- data/lib/hecks/site/bluebook/site.bluebook +5 -5
- data/lib/hecks/sme/bluebook/sme.bluebook +12 -12
- data/lib/hecks/tickets/bluebook/tickets.bluebook +3 -3
- data/lib/hecks/tools/fuzz_sweep/findings.rb +3 -9
- data/lib/hecks/tools/fuzz_sweep.rb +1 -3
- data/lib/hecks/tools/translation_audit/verdicts.rb +3 -28
- data/lib/hecks/tools/translation_audit.rb +8 -10
- data/lib/hecks/tools/translation_scaffold/reporting.rb +1 -1
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- data/lib/hecks.rb +3 -0
- data/prebuilt/{58208399bada10b0db99928c48e38789288fc58f7b71a72b21bfa386680e32ed.marshal → 02c09c73b272c1fb5a9ae6d87bb5e5e529208e00f6b4b597dc2c0fd70a9c0964.marshal} +0 -0
- data/prebuilt/{2b59737fbdbd5f44edda0fddce2611f45d76087b3a7c782d1f1a17ea58c77948.marshal → bb84a835cf6a15136bad5ca03aa5bf93a5b92f5f72444618d3ef2412b987fb32.marshal} +0 -0
- data/prebuilt/verdicts-f1f10da14c8e8f01b4c9b7e3e2d74be8cd2bf4676364ed17fa16975d2a076d75.json +1 -0
- data/rust/Cargo.toml +40 -0
- data/rust/build/Cargo.toml +36 -0
- data/rust/build/clippy.toml +4 -0
- data/rust/build/src/adapters_pass.rs +196 -0
- data/rust/build/src/binding_pass.rs +54 -13
- data/rust/build/src/build_artifact.rs +23 -5
- data/rust/build/src/cargo_sync.rs +69 -26
- data/rust/build/src/lineage_pass.rs +101 -87
- data/rust/build/src/main.rs +11 -2
- data/rust/build/src/pipeline.rs +80 -19
- data/rust/build/src/reserved_names.rs +6 -50
- data/rust/build/src/resolve.rs +82 -35
- data/rust/build/src/rustfmt.rs +100 -0
- data/rust/build/src/seams_pass.rs +85 -16
- data/rust/build/src/subprocess.rs +30 -6
- data/rust/build/src/tmp.rs +5 -2
- data/rust/build/src/translation_pass.rs +72 -22
- data/rust/build/src/world_pass.rs +109 -0
- data/rust/clippy.toml +4 -0
- data/rust/codegen/Cargo.toml +36 -0
- data/rust/codegen/clippy.toml +4 -0
- data/rust/codegen/src/asks.rs +40 -14
- data/rust/codegen/src/attr.rs +2 -2
- data/rust/codegen/src/bridging.rs +676 -117
- data/rust/codegen/src/commands.rs +979 -373
- data/rust/codegen/src/constraints.rs +88 -25
- data/rust/codegen/src/dependency_planning.rs +128 -34
- data/rust/codegen/src/domain_generator.rs +300 -170
- data/rust/codegen/src/exemplar.rs +87 -32
- data/rust/codegen/src/expr_emitter.rs +54 -6
- data/rust/codegen/src/fielded.rs +193 -41
- data/rust/codegen/src/hecks_naming.rs +124 -16
- data/rust/codegen/src/hecks_naming_tests.rs +81 -0
- data/rust/codegen/src/json.rs +80 -44
- data/rust/codegen/src/json_codec.rs +585 -149
- data/rust/codegen/src/literal.rs +104 -138
- data/rust/codegen/src/main.rs +145 -52
- data/rust/codegen/src/manifest.rs +76 -14
- data/rust/codegen/src/mutations.rs +1212 -239
- data/rust/codegen/src/naming.rs +111 -60
- data/rust/codegen/src/optional_pass.rs +90 -28
- data/rust/codegen/src/ports.rs +5 -4
- data/rust/codegen/src/prelude.rs +139 -49
- data/rust/codegen/src/queries.rs +601 -226
- data/rust/codegen/src/reactions.rs +268 -74
- data/rust/codegen/src/read_models.rs +606 -205
- data/rust/codegen/src/reference_specs.rs +16 -3
- data/rust/codegen/src/registry.rs +128 -79
- data/rust/codegen/src/reserved_names.rs +6 -50
- data/rust/codegen/src/shared.rs +71 -22
- data/rust/codegen/src/sidecars.rs +15 -5
- data/rust/codegen/src/skip_reason.rs +8 -2
- data/rust/codegen/src/types.rs +306 -108
- data/rust/host/Cargo.lock +1 -0
- data/rust/host/Cargo.toml +40 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/clippy.toml +4 -0
- data/rust/host/src/api.rs +698 -218
- data/rust/host/src/auth.rs +491 -214
- data/rust/host/src/b64.rs +91 -0
- data/rust/host/src/bin/expr_harness.rs +6 -2
- data/rust/host/src/bin/lineage_harness.rs +47 -11
- data/rust/host/src/bin/mint_harness.rs +70 -37
- data/rust/host/src/bin/validate_harness.rs +16 -8
- data/rust/host/src/boot.rs +144 -68
- data/rust/host/src/boundary_fuzz/api.rs +130 -32
- data/rust/host/src/boundary_fuzz/auth.rs +108 -23
- data/rust/host/src/boundary_fuzz/expr_json.rs +85 -11
- data/rust/host/src/boundary_fuzz/ir.rs +164 -12
- data/rust/host/src/boundary_fuzz/server.rs +283 -36
- data/rust/host/src/boundary_fuzz/web.rs +56 -8
- data/rust/host/src/checkout.rs +78 -32
- data/rust/host/src/clock.rs +8 -0
- data/rust/host/src/commerce_ir.rs +333 -95
- data/rust/host/src/crypto.rs +33 -0
- data/rust/host/src/dispatch.rs +990 -331
- data/rust/host/src/expr_json.rs +1021 -273
- data/rust/host/src/extension.rs +24 -4
- data/rust/host/src/field_hints.rs +11 -7
- data/rust/host/src/fuzz_support.rs +85 -13
- data/rust/host/src/host.rs +100 -0
- data/rust/host/src/http.rs +22 -0
- data/rust/host/src/ir.rs +326 -16
- data/rust/host/src/journal.rs +277 -176
- data/rust/host/src/lambda_client.rs +219 -53
- data/rust/host/src/lib.rs +16 -4
- data/rust/host/src/log.rs +62 -11
- data/rust/host/src/mint.rs +1153 -326
- data/rust/host/src/needs.rs +185 -44
- data/rust/host/src/payments/keystore.rs +197 -45
- data/rust/host/src/payments/roles.rs +191 -0
- data/rust/host/src/payments/tests.rs +1445 -275
- data/rust/host/src/payments.rs +409 -121
- data/rust/host/src/platform_signin/attack_tests.rs +5 -5
- data/rust/host/src/platform_signin/config.rs +11 -11
- data/rust/host/src/platform_signin/cookie.rs +2 -2
- data/rust/host/src/platform_signin/extension.rs +2 -1
- data/rust/host/src/platform_signin/handler.rs +9 -8
- data/rust/host/src/platform_signin/jti.rs +4 -2
- data/rust/host/src/platform_signin/jwks.rs +2 -1
- data/rust/host/src/platform_signin/jws.rs +2 -37
- data/rust/host/src/platform_signin/known_answer_tests.rs +5 -1
- data/rust/host/src/platform_signin/mode_tests.rs +5 -3
- data/rust/host/src/platform_signin/pages.rs +20 -20
- data/rust/host/src/platform_signin/session.rs +8 -1
- data/rust/host/src/platform_signin/settings.rs +6 -6
- data/rust/host/src/platform_signin/strict_json.rs +12 -9
- data/rust/host/src/platform_signin/verify.rs +41 -41
- data/rust/host/src/platform_signin/wiring_support.rs +13 -18
- data/rust/host/src/presentation.rs +207 -63
- data/rust/host/src/presentation_write.rs +502 -187
- data/rust/host/src/query_step.rs +69 -18
- data/rust/host/src/rate_limit.rs +541 -142
- data/rust/host/src/reference_transform.rs +77 -25
- data/rust/host/src/reference_validate.rs +443 -104
- data/rust/host/src/resend.rs +190 -55
- data/rust/host/src/secrets.rs +116 -21
- data/rust/host/src/server.rs +483 -104
- data/rust/host/src/storage_shape.rs +88 -21
- data/rust/host/src/test_pg.rs +17 -7
- data/rust/host/src/ui_schema.rs +495 -207
- data/rust/host/src/wasm_runner.rs +21 -6
- data/rust/host/src/web/commerce.rs +101 -23
- data/rust/host/src/web/newsletter/cooldown.rs +10 -3
- data/rust/host/src/web/newsletter/template.rs +26 -7
- data/rust/host/src/web/newsletter/tests.rs +112 -23
- data/rust/host/src/web/newsletter.rs +209 -59
- data/rust/host/src/web/newsletter_send.rs +251 -79
- data/rust/host/src/web/registration_receipt/tests.rs +16 -4
- data/rust/host/src/web/registration_receipt.rs +100 -24
- data/rust/host/src/web/registrations.rs +1384 -332
- data/rust/host/src/web/resend_webhook.rs +138 -80
- data/rust/host/src/web.rs +2416 -508
- data/rust/lsp/Cargo.toml +36 -0
- data/rust/lsp/clippy.toml +4 -0
- data/rust/lsp/src/json.rs +29 -22
- data/rust/lsp/src/main.rs +81 -31
- data/rust/lsp/src/outline.rs +23 -7
- data/rust/lsp/src/rpc.rs +17 -4
- data/rust/parser/Cargo.toml +36 -0
- data/rust/parser/clippy.toml +4 -0
- data/rust/parser/src/build/closed_sets.rs +6 -1
- data/rust/parser/src/build/identity.rs +4 -3
- data/rust/parser/src/build/mod.rs +1 -0
- data/rust/parser/src/build/named_pattern.rs +26 -0
- data/rust/parser/src/build/naming.rs +2 -80
- data/rust/parser/src/build/pattern_subset.rs +6 -8
- data/rust/parser/src/build/query_derive.rs +3 -1
- data/rust/parser/src/build/query_inference.rs +14 -5
- data/rust/parser/src/build/read_model.rs +4 -1
- data/rust/parser/src/build/references.rs +4 -3
- data/rust/parser/src/canonical.rs +34 -15
- data/rust/parser/src/emit.rs +187 -105
- data/rust/parser/src/expr/ast_json.rs +134 -42
- data/rust/parser/src/expr/evaluator.rs +27 -9
- data/rust/parser/src/expr/mod.rs +54 -13
- data/rust/parser/src/expr/resolver.rs +299 -92
- data/rust/parser/src/ir.rs +35 -3
- data/rust/parser/src/keywords.rs +7168 -510
- data/rust/parser/src/lex.rs +11 -25
- data/rust/parser/src/lib.rs +1 -0
- data/rust/parser/src/main.rs +65 -6
- data/rust/parser/src/parse/adapter.rs +157 -0
- data/rust/parser/src/parse/aggregate.rs +19 -10
- data/rust/parser/src/parse/chapter.rs +16 -17
- data/rust/parser/src/parse/command.rs +35 -16
- data/rust/parser/src/parse/domain_port.rs +4 -7
- data/rust/parser/src/parse/entity.rs +21 -11
- data/rust/parser/src/parse/hecksagon.rs +10 -4
- data/rust/parser/src/parse/lifecycle.rs +9 -5
- data/rust/parser/src/parse/mod.rs +43 -91
- data/rust/parser/src/parse/needs.rs +21 -12
- data/rust/parser/src/parse/policy.rs +2 -2
- data/rust/parser/src/parse/process_manager.rs +9 -11
- data/rust/parser/src/parse/query.rs +8 -4
- data/rust/parser/src/parse/read_model.rs +57 -16
- data/rust/parser/src/parse/value_object.rs +7 -2
- data/rust/parser/src/parse/world.rs +322 -0
- data/rust/parser/src/ruby_value.rs +7 -6
- data/rust/src/exemplar/json.rs +4 -4
- data/rust/src/exemplar/mutations.rs +18 -0
- data/rust/src/exemplar/read_models.rs +1 -10
- data/rust/src/kernel/attribute_shapes/composite.rs +54 -14
- data/rust/src/kernel/attribute_shapes/list.rs +2 -2
- data/rust/src/kernel/attribute_shapes/mod.rs +8 -8
- data/rust/src/kernel/attribute_shapes/scalar.rs +2 -2
- data/rust/src/kernel/cli.rs +229 -112
- data/rust/src/kernel/dispatch.rs +323 -115
- data/rust/src/kernel/dispatch_guard_tests.rs +327 -65
- data/rust/src/kernel/expr.rs +203 -86
- data/rust/src/kernel/expression_operators/arithmetic.rs +138 -25
- data/rust/src/kernel/expression_operators/comparison.rs +87 -2
- data/rust/src/kernel/expression_operators/enumeration.rs +106 -22
- data/rust/src/kernel/expression_operators/logical.rs +9 -3
- data/rust/src/kernel/expression_operators/membership.rs +3 -1
- data/rust/src/kernel/expression_operators/mod.rs +34 -21
- data/rust/src/kernel/expression_operators/pattern_match.rs +46 -11
- data/rust/src/kernel/expression_operators/positional.rs +84 -30
- data/rust/src/kernel/expression_operators/presence.rs +52 -13
- data/rust/src/kernel/expression_operators/sign_test.rs +3 -1
- data/rust/src/kernel/expression_operators/sized.rs +24 -8
- data/rust/src/kernel/expression_operators/text.rs +120 -27
- data/rust/src/kernel/expression_operators/to_string.rs +10 -4
- data/rust/src/kernel/json.rs +230 -148
- data/rust/src/kernel/mod.rs +46 -33
- data/rust/src/kernel/named_query.rs +63 -15
- data/rust/src/kernel/needs.rs +112 -25
- data/rust/src/kernel/orchestrate.rs +980 -581
- data/rust/src/kernel/orchestrate_guard_tests.rs +538 -140
- data/rust/src/kernel/pattern.rs +107 -34
- data/rust/src/kernel/query_comparators.rs +118 -41
- data/rust/src/kernel/query_ordering.rs +13 -5
- data/rust/src/kernel/read_model.rs +479 -181
- data/rust/src/kernel/reference_lookup.rs +122 -26
- data/rust/src/kernel/repository.rs +293 -66
- data/rust/src/kernel/routing.rs +58 -42
- data/rust/src/kernel/ruby_float.rs +139 -0
- data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +57 -57
- data/rust/src/kernel/vocab/entity_dispatch_order.rs +54 -54
- data/rust/src/kernel/vocab/field_hint.rs +19 -25
- data/rust/src/kernel/vocab/mod.rs +6 -6
- data/rust/src/kernel/vocab/query_comparator.rs +23 -24
- data/rust/src/kernel/vocab/refusal_template.rs +501 -297
- data/rust/src/lib.rs +50 -0
- data/rust/src/main.rs +4 -1
- data/rust/web/Cargo.toml +36 -0
- data/rust/web/clippy.toml +4 -0
- metadata +85 -15
- data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +0 -36
- data/lib/hecks/adapters/driven/google_authentication.adapter +0 -3
- data/lib/hecks/adapters/driven/google_authentication.rb +0 -99
- data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +0 -3
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +0 -32
- data/lib/hecks/ports/authentication.port +0 -6
- data/lib/hecks/ports/authentication.rb +0 -67
- data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +0 -255
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +0 -22
- data/prebuilt/verdicts-48318ca172edd21443b1ad02801c697fff100b746e828f09db987647641a5df2.json +0 -1
- data/rust/host/src/approval.rs +0 -652
|
@@ -60,7 +60,7 @@ pub struct DispatchSpec {
|
|
|
60
60
|
pub with: &'static [(&'static str, WithValue)],
|
|
61
61
|
/// The command that undoes this dispatch, if any. A reference so the const table can nest it;
|
|
62
62
|
/// a compensation is never itself compensable.
|
|
63
|
-
pub compensates: Option<&'static
|
|
63
|
+
pub compensates: Option<&'static Self>,
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/// One leg of a process manager: on `event_type` from `from_state`, move to `to_state` and
|
|
@@ -105,7 +105,9 @@ pub struct CompletedCompensation {
|
|
|
105
105
|
|
|
106
106
|
// Build refuses two legs on one (event, state) pair, so the first match is the only one.
|
|
107
107
|
fn select_leg<'a>(pm: &'a ProcessManagerDef, event: &str, state: &str) -> Option<&'a Handler> {
|
|
108
|
-
pm.handlers
|
|
108
|
+
pm.handlers
|
|
109
|
+
.iter()
|
|
110
|
+
.find(|h| h.event_type == event && h.from_state == state)
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
fn leg_mismatch(pm: &ProcessManagerDef, event: &str, state: &str) -> String {
|
|
@@ -124,7 +126,11 @@ fn correlation_head(correlates_by: &str) -> &str {
|
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
// An empty correlation counts as absent, so callers need only one check.
|
|
127
|
-
fn correlation_of(
|
|
129
|
+
fn correlation_of(
|
|
130
|
+
pm: &ProcessManagerDef,
|
|
131
|
+
event: &Event,
|
|
132
|
+
reference_key_fn: fn(&str) -> Option<&'static str>,
|
|
133
|
+
) -> Option<String> {
|
|
128
134
|
// Tier 1: a dotted path into the event's payload.
|
|
129
135
|
if let Some(v) = event.payload.dig(pm.correlates_by) {
|
|
130
136
|
if let Ok(id) = v.to_id_component() {
|
|
@@ -153,7 +159,13 @@ fn correlation_of(pm: &ProcessManagerDef, event: &Event, reference_key_fn: fn(&s
|
|
|
153
159
|
}
|
|
154
160
|
}
|
|
155
161
|
|
|
156
|
-
fn resolve_with(
|
|
162
|
+
fn resolve_with(
|
|
163
|
+
pm: &ProcessManagerDef,
|
|
164
|
+
value: &WithValue,
|
|
165
|
+
event: &Event,
|
|
166
|
+
correlation: &str,
|
|
167
|
+
memory: &Json,
|
|
168
|
+
) -> Json {
|
|
157
169
|
match value {
|
|
158
170
|
WithValue::Literal(build) => build(),
|
|
159
171
|
WithValue::Ref(name) => {
|
|
@@ -178,12 +190,30 @@ fn qualify_saga_command_name(domain_name: &str, command_name: &str) -> String {
|
|
|
178
190
|
format!("{domain_name}::{}", command_name.replacen("::", ".", 1))
|
|
179
191
|
}
|
|
180
192
|
|
|
181
|
-
fn build_dispatch_args(
|
|
182
|
-
|
|
193
|
+
fn build_dispatch_args(
|
|
194
|
+
pm: &ProcessManagerDef,
|
|
195
|
+
spec: &DispatchSpec,
|
|
196
|
+
event: &Event,
|
|
197
|
+
correlation: &str,
|
|
198
|
+
memory: &Json,
|
|
199
|
+
domain_name: &str,
|
|
200
|
+
tables: &Tables,
|
|
201
|
+
) -> Json {
|
|
202
|
+
let projected = Json::Object(
|
|
203
|
+
spec.with
|
|
204
|
+
.iter()
|
|
205
|
+
.map(|(key, value)| {
|
|
206
|
+
(
|
|
207
|
+
key.to_string(),
|
|
208
|
+
resolve_with(pm, value, event, correlation, memory),
|
|
209
|
+
)
|
|
210
|
+
})
|
|
211
|
+
.collect(),
|
|
212
|
+
);
|
|
183
213
|
// A saga leg's `with:` is always an explicit projection. The verb is qualified before the
|
|
184
214
|
// lookup because the tables are keyed by the qualified verb.
|
|
185
215
|
let qualified = qualify_saga_command_name(domain_name, spec.command_name);
|
|
186
|
-
route_dispatch_args(projected, &qualified, tables, event)
|
|
216
|
+
route_dispatch_args(&projected, &qualified, tables, event)
|
|
187
217
|
}
|
|
188
218
|
|
|
189
219
|
#[derive(Clone, Copy)]
|
|
@@ -201,14 +231,65 @@ pub struct Tables<'a> {
|
|
|
201
231
|
pub entity_identity_head_fn: fn(&str) -> Option<&'static str>,
|
|
202
232
|
}
|
|
203
233
|
|
|
234
|
+
/// The shape of the generated `dispatch` an orchestration step calls into: the store, the verb,
|
|
235
|
+
/// its arguments, the caller's role and actor, and the mutation log it appends to.
|
|
236
|
+
type DispatchFn<S> = fn(
|
|
237
|
+
&mut S,
|
|
238
|
+
&str,
|
|
239
|
+
&Json,
|
|
240
|
+
Option<&str>,
|
|
241
|
+
Option<&str>,
|
|
242
|
+
&mut Vec<MutationRecord>,
|
|
243
|
+
) -> Result<Vec<Event>, Refusal>;
|
|
244
|
+
|
|
245
|
+
/// What one cascade threads through every reaction it triggers: the store it dispatches against
|
|
246
|
+
/// and the logs it appends to. `depth` is an argument, not state, because it grows with each
|
|
247
|
+
/// re-entry.
|
|
248
|
+
struct Cascade<'a, S: AggregateScan> {
|
|
249
|
+
store: &'a mut S,
|
|
250
|
+
dispatch_fn: DispatchFn<S>,
|
|
251
|
+
tables: Tables<'static>,
|
|
252
|
+
sagas: &'a mut HashMap<(String, String), SagaInstance>,
|
|
253
|
+
// One host-supplied moment for the whole cascade.
|
|
254
|
+
occurred_at: Option<&'a str>,
|
|
255
|
+
all_events: &'a mut Vec<Event>,
|
|
256
|
+
mutations: &'a mut Vec<MutationRecord>,
|
|
257
|
+
cross_domain: &'a mut Vec<PendingCrossDomainReaction>,
|
|
258
|
+
reaction_log: &'a mut Vec<Json>,
|
|
259
|
+
saga_log: &'a mut Vec<Json>,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/// One process-manager instance's leg of a cascade: which manager, which conversation, and the
|
|
263
|
+
/// domain its commands are qualified with.
|
|
264
|
+
struct SagaLeg<'a> {
|
|
265
|
+
pm: &'a ProcessManagerDef,
|
|
266
|
+
correlation: &'a str,
|
|
267
|
+
domain_name: &'a str,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
impl SagaLeg<'_> {
|
|
271
|
+
fn key(&self) -> (String, String) {
|
|
272
|
+
(self.pm.name.to_string(), self.correlation.to_string())
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/// The correlation every event this leg's dispatches produce is stamped with.
|
|
276
|
+
fn stamp(&self) -> HashMap<String, String> {
|
|
277
|
+
HashMap::from([(
|
|
278
|
+
correlation_head(self.pm.correlates_by).to_string(),
|
|
279
|
+
self.correlation.to_string(),
|
|
280
|
+
)])
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
204
284
|
/// Dispatches `verb`, then recursively runs the policies and saga legs its events trigger.
|
|
205
285
|
/// Only a refusal of the top-level `verb` is returned; reaction refusals are logged and swallowed.
|
|
206
286
|
///
|
|
207
287
|
/// `saga_correlation` stamps every event this call produces; only saga-leg dispatches pass it.
|
|
208
|
-
|
|
288
|
+
// reason: every caller, here and in the generated code, holds default-hasher maps.
|
|
289
|
+
#[allow(clippy::too_many_arguments, clippy::implicit_hasher)]
|
|
209
290
|
pub fn orchestrate<S: AggregateScan>(
|
|
210
291
|
store: &mut S,
|
|
211
|
-
dispatch_fn:
|
|
292
|
+
dispatch_fn: DispatchFn<S>,
|
|
212
293
|
tables: Tables<'static>,
|
|
213
294
|
sagas: &mut HashMap<(String, String), SagaInstance>,
|
|
214
295
|
verb: &str,
|
|
@@ -226,41 +307,84 @@ pub fn orchestrate<S: AggregateScan>(
|
|
|
226
307
|
reaction_log: &mut Vec<Json>,
|
|
227
308
|
saga_log: &mut Vec<Json>,
|
|
228
309
|
) -> Result<(), Refusal> {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
310
|
+
Cascade {
|
|
311
|
+
store,
|
|
312
|
+
dispatch_fn,
|
|
313
|
+
tables,
|
|
314
|
+
sagas,
|
|
315
|
+
occurred_at,
|
|
316
|
+
all_events,
|
|
317
|
+
mutations,
|
|
318
|
+
cross_domain,
|
|
319
|
+
reaction_log,
|
|
320
|
+
saga_log,
|
|
321
|
+
}
|
|
322
|
+
.run(
|
|
323
|
+
verb,
|
|
324
|
+
args,
|
|
325
|
+
caller_role,
|
|
326
|
+
caller_actor_id,
|
|
327
|
+
saga_correlation,
|
|
328
|
+
depth,
|
|
329
|
+
)
|
|
330
|
+
}
|
|
234
331
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
332
|
+
impl<S: AggregateScan> Cascade<'_, S> {
|
|
333
|
+
fn run(
|
|
334
|
+
&mut self,
|
|
335
|
+
verb: &str,
|
|
336
|
+
args: &Json,
|
|
337
|
+
caller_role: Option<&str>,
|
|
338
|
+
caller_actor_id: Option<&str>,
|
|
339
|
+
saga_correlation: Option<&HashMap<String, String>>,
|
|
340
|
+
depth: usize,
|
|
341
|
+
) -> Result<(), Refusal> {
|
|
342
|
+
// A fact the command `needs` is answered ahead of every gate (ADR 0081), for a reaction's
|
|
343
|
+
// dispatch as for the outermost one.
|
|
344
|
+
let enriched = super::needs::enrich(verb, args, self.occurred_at);
|
|
345
|
+
let args = enriched.as_ref().unwrap_or(args);
|
|
346
|
+
let mut events = (self.dispatch_fn)(
|
|
347
|
+
self.store,
|
|
348
|
+
verb,
|
|
349
|
+
args,
|
|
350
|
+
caller_role,
|
|
351
|
+
caller_actor_id,
|
|
352
|
+
self.mutations,
|
|
353
|
+
)?;
|
|
354
|
+
|
|
355
|
+
if let Some(stamp) = saga_correlation {
|
|
356
|
+
for event in &mut events {
|
|
357
|
+
event
|
|
358
|
+
.correlation
|
|
359
|
+
.get_or_insert_with(HashMap::new)
|
|
360
|
+
.extend(stamp.iter().map(|(k, v)| (k.clone(), v.clone())));
|
|
361
|
+
}
|
|
238
362
|
}
|
|
239
|
-
}
|
|
240
363
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
364
|
+
if let Some(ts) = self.occurred_at {
|
|
365
|
+
for event in &mut events {
|
|
366
|
+
event.occurred_at = Some(ts.to_string());
|
|
367
|
+
}
|
|
244
368
|
}
|
|
245
|
-
}
|
|
246
369
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
370
|
+
// Log the whole batch before any reaction runs, so a later event never logs after an
|
|
371
|
+
// earlier event's reactions.
|
|
372
|
+
for event in &events {
|
|
373
|
+
self.all_events.push(event.clone());
|
|
374
|
+
}
|
|
252
375
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
376
|
+
// Then per event in `emits` order: its policies, then its sagas.
|
|
377
|
+
for event in events {
|
|
378
|
+
// Saga begin/advance/end run for every event regardless of depth; only the re-entry is
|
|
379
|
+
// depth-gated, inside `react_policies` and `deliver_saga_dispatch`.
|
|
380
|
+
self.react_policies(&event, depth);
|
|
381
|
+
self.begin_saga(&event);
|
|
382
|
+
self.advance_saga(&event, depth);
|
|
383
|
+
self.end_saga(&event);
|
|
384
|
+
}
|
|
262
385
|
|
|
263
|
-
|
|
386
|
+
Ok(())
|
|
387
|
+
}
|
|
264
388
|
}
|
|
265
389
|
|
|
266
390
|
// Decodes a rendered `with:` literal. Wire text keeps its quote marks, so passing it through
|
|
@@ -321,13 +445,22 @@ fn offer_row_fields(source: &mut Vec<(String, Json)>, row_id: &str, record: &Jso
|
|
|
321
445
|
|
|
322
446
|
fn where_holds(where_expr: Option<fn() -> Expr>, event: &Event) -> bool {
|
|
323
447
|
let Some(build) = where_expr else { return true };
|
|
324
|
-
let ctx = EvalContext {
|
|
448
|
+
let ctx = EvalContext {
|
|
449
|
+
args: &event.payload,
|
|
450
|
+
instance: &NoFields,
|
|
451
|
+
};
|
|
325
452
|
matches!(interpret(&build(), &ctx), Ok(v) if v.truthy())
|
|
326
453
|
}
|
|
327
454
|
|
|
328
455
|
// An empty `with_spec` forwards the whole payload. `extra` (a fan-out's row key) is merged into
|
|
329
456
|
// the source before projection, not onto its result.
|
|
330
|
-
fn trigger_args(
|
|
457
|
+
fn trigger_args(
|
|
458
|
+
policy: &PolicyRule,
|
|
459
|
+
event: &Event,
|
|
460
|
+
extra: Option<(&str, &str)>,
|
|
461
|
+
target_verb: &str,
|
|
462
|
+
tables: &Tables,
|
|
463
|
+
) -> Json {
|
|
331
464
|
trigger_args_with_row(policy, event, extra, None, target_verb, tables)
|
|
332
465
|
}
|
|
333
466
|
|
|
@@ -342,7 +475,11 @@ fn for_each_query_args(def: &named_query::QueryDef, event: &Event, tables: &Tabl
|
|
|
342
475
|
Json::Object(pairs) => pairs.clone(),
|
|
343
476
|
_ => Vec::new(),
|
|
344
477
|
};
|
|
345
|
-
let reads = |head: &str|
|
|
478
|
+
let reads = |head: &str| {
|
|
479
|
+
def.conditions
|
|
480
|
+
.iter()
|
|
481
|
+
.any(|c| matches!(c.value, named_query::QueryConditionValue::Arg(name) if name == head))
|
|
482
|
+
};
|
|
346
483
|
if let Some(head) = (tables.identity_head_fn)(&event.aggregate) {
|
|
347
484
|
if !event.id.is_empty() && reads(head) && !args.iter().any(|(name, _)| name == head) {
|
|
348
485
|
args.push((head.to_string(), Json::str(event.id.clone())));
|
|
@@ -352,7 +489,12 @@ fn for_each_query_args(def: &named_query::QueryDef, event: &Event, tables: &Tabl
|
|
|
352
489
|
}
|
|
353
490
|
|
|
354
491
|
fn trigger_args_with_row(
|
|
355
|
-
policy: &PolicyRule,
|
|
492
|
+
policy: &PolicyRule,
|
|
493
|
+
event: &Event,
|
|
494
|
+
extra: Option<(&str, &str)>,
|
|
495
|
+
row: Option<(&str, &Json)>,
|
|
496
|
+
target_verb: &str,
|
|
497
|
+
tables: &Tables,
|
|
356
498
|
) -> Json {
|
|
357
499
|
let mut source: Vec<(String, Json)> = match &event.payload {
|
|
358
500
|
Json::Object(pairs) => pairs.clone(),
|
|
@@ -360,7 +502,7 @@ fn trigger_args_with_row(
|
|
|
360
502
|
};
|
|
361
503
|
if let Some((key, id)) = &extra {
|
|
362
504
|
source.retain(|(name, _)| name != key);
|
|
363
|
-
source.push(((*key).to_string(), Json::str(id.
|
|
505
|
+
source.push(((*key).to_string(), Json::str((*id).to_string())));
|
|
364
506
|
}
|
|
365
507
|
|
|
366
508
|
if policy.with_spec.is_empty() {
|
|
@@ -373,7 +515,10 @@ fn trigger_args_with_row(
|
|
|
373
515
|
// The lifted receiver replaces any `to` the payload carried; that fact never
|
|
374
516
|
// reaches the target.
|
|
375
517
|
source.retain(|(name, _)| name != "to");
|
|
376
|
-
return Json::obj(vec![
|
|
518
|
+
return Json::obj(vec![
|
|
519
|
+
("to", Json::str(event.id.clone())),
|
|
520
|
+
("with", Json::Object(source)),
|
|
521
|
+
]);
|
|
377
522
|
}
|
|
378
523
|
}
|
|
379
524
|
}
|
|
@@ -396,14 +541,15 @@ fn trigger_args_with_row(
|
|
|
396
541
|
.with_spec
|
|
397
542
|
.iter()
|
|
398
543
|
.map(|(name, binding)| {
|
|
399
|
-
let value =
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
544
|
+
let value = binding.strip_prefix(':').map_or_else(
|
|
545
|
+
|| read_literal_wire(binding),
|
|
546
|
+
|field| {
|
|
547
|
+
source
|
|
548
|
+
.iter()
|
|
549
|
+
.find(|(held, _)| held == field)
|
|
550
|
+
.map_or(Json::Null, |(_, held)| held.clone())
|
|
551
|
+
},
|
|
552
|
+
);
|
|
407
553
|
((*name).to_string(), value)
|
|
408
554
|
})
|
|
409
555
|
.collect();
|
|
@@ -413,11 +559,15 @@ fn trigger_args_with_row(
|
|
|
413
559
|
// non-creating target on the same aggregate. Projected facts, even a `to`, stay facts.
|
|
414
560
|
if extra.is_none() && !(tables.command_creates_fn)(target_verb) {
|
|
415
561
|
if let Json::Object(pairs) = &routed {
|
|
416
|
-
let already_routed =
|
|
562
|
+
let already_routed =
|
|
563
|
+
pairs.iter().any(|(k, _)| k == "to") && pairs.iter().any(|(k, _)| k == "with");
|
|
417
564
|
if !already_routed {
|
|
418
565
|
if let Some(aggregate_name) = target_verb.rsplit_once('.').map(|(agg, _)| agg) {
|
|
419
566
|
if aggregate_name == event.aggregate {
|
|
420
|
-
return Json::obj(vec![
|
|
567
|
+
return Json::obj(vec![
|
|
568
|
+
("to", Json::str(event.id.clone())),
|
|
569
|
+
("with", routed),
|
|
570
|
+
]);
|
|
421
571
|
}
|
|
422
572
|
}
|
|
423
573
|
}
|
|
@@ -430,9 +580,15 @@ fn trigger_args_with_row(
|
|
|
430
580
|
// identity. Composite identities and targets the tables know nothing about stay flat: unknown
|
|
431
581
|
// means don't route. `facts` slices the original pairs to the command's declared attributes.
|
|
432
582
|
fn split_routed_args(projected: Json, target_verb: &str, tables: &Tables) -> Json {
|
|
433
|
-
let Json::Object(pairs) = projected else {
|
|
583
|
+
let Json::Object(pairs) = projected else {
|
|
584
|
+
return projected;
|
|
585
|
+
};
|
|
434
586
|
let declared = (tables.command_attributes_fn)(target_verb);
|
|
435
|
-
let facts: Vec<(String, Json)> = pairs
|
|
587
|
+
let facts: Vec<(String, Json)> = pairs
|
|
588
|
+
.iter()
|
|
589
|
+
.filter(|(k, _)| declared.contains(&k.as_str()))
|
|
590
|
+
.cloned()
|
|
591
|
+
.collect();
|
|
436
592
|
|
|
437
593
|
if (tables.command_creates_fn)(target_verb) {
|
|
438
594
|
return Json::Object(facts);
|
|
@@ -442,12 +598,17 @@ fn split_routed_args(projected: Json, target_verb: &str, tables: &Tables) -> Jso
|
|
|
442
598
|
return Json::Object(facts);
|
|
443
599
|
};
|
|
444
600
|
|
|
445
|
-
let candidates = [
|
|
601
|
+
let candidates = [
|
|
602
|
+
(tables.identity_head_fn)(aggregate_name),
|
|
603
|
+
(tables.reference_key_fn)(aggregate_name),
|
|
604
|
+
];
|
|
446
605
|
for key in candidates.into_iter().flatten() {
|
|
447
606
|
if let Some((_, raw_id)) = pairs.iter().find(|(k, _)| k == key) {
|
|
448
607
|
// A value-object identity arrives as `{"value": ...}`; `resolved_id_component`
|
|
449
608
|
// unwraps it.
|
|
450
|
-
let Some(id) = resolved_id_component(raw_id) else {
|
|
609
|
+
let Some(id) = resolved_id_component(raw_id) else {
|
|
610
|
+
continue;
|
|
611
|
+
};
|
|
451
612
|
return Json::obj(vec![("to", Json::str(id)), ("with", Json::Object(facts))]);
|
|
452
613
|
}
|
|
453
614
|
}
|
|
@@ -467,543 +628,591 @@ fn entity_command_paths(target_verb: &str) -> Option<(&str, String)> {
|
|
|
467
628
|
|
|
468
629
|
// A value-object field is stored as `{"value": ...}`: try the value as-is, then its `value` once.
|
|
469
630
|
fn resolved_id_component(v: &Json) -> Option<String> {
|
|
470
|
-
v.to_id_component()
|
|
631
|
+
v.to_id_component()
|
|
632
|
+
.ok()
|
|
633
|
+
.or_else(|| {
|
|
634
|
+
v.get("value")
|
|
635
|
+
.and_then(|inner| inner.to_id_component().ok())
|
|
636
|
+
})
|
|
637
|
+
.filter(|id| !id.is_empty())
|
|
471
638
|
}
|
|
472
639
|
|
|
473
640
|
// Routes a one-level entity command that `split_routed_args` cannot: the parent aggregate's
|
|
474
641
|
// identity comes from the triggering event, not from the `with:` facts. Every other shape falls
|
|
475
642
|
// through to `split_routed_args`'s answer.
|
|
476
|
-
fn route_dispatch_args(
|
|
643
|
+
fn route_dispatch_args(
|
|
644
|
+
projected: &Json,
|
|
645
|
+
target_verb: &str,
|
|
646
|
+
tables: &Tables,
|
|
647
|
+
event: &Event,
|
|
648
|
+
) -> Json {
|
|
477
649
|
let routed = split_routed_args(projected.clone(), target_verb, tables);
|
|
478
650
|
if matches!(&routed, Json::Object(fields) if fields.iter().any(|(k, _)| k == "to")) {
|
|
479
651
|
return routed;
|
|
480
652
|
}
|
|
481
653
|
|
|
482
|
-
let Some((aggregate_name, entity_qualified)) = entity_command_paths(target_verb) else {
|
|
483
|
-
|
|
654
|
+
let Some((aggregate_name, entity_qualified)) = entity_command_paths(target_verb) else {
|
|
655
|
+
return routed;
|
|
656
|
+
};
|
|
657
|
+
let Json::Object(pairs) = projected else {
|
|
658
|
+
return routed;
|
|
659
|
+
};
|
|
484
660
|
|
|
485
661
|
// Entity identity: its identity attribute, present in the `with:` facts by that name.
|
|
486
|
-
let Some(entity_head) = (tables.entity_identity_head_fn)(&entity_qualified) else {
|
|
487
|
-
|
|
662
|
+
let Some(entity_head) = (tables.entity_identity_head_fn)(&entity_qualified) else {
|
|
663
|
+
return routed;
|
|
664
|
+
};
|
|
665
|
+
let Some(entity_id) = pairs
|
|
666
|
+
.iter()
|
|
667
|
+
.find(|(k, _)| k == entity_head)
|
|
668
|
+
.and_then(|(_, v)| resolved_id_component(v))
|
|
669
|
+
else {
|
|
488
670
|
return routed;
|
|
489
671
|
};
|
|
490
672
|
|
|
491
673
|
// Aggregate identity: a projected identity/reference key, else the triggering event's own id
|
|
492
674
|
// when it is the same aggregate.
|
|
493
|
-
let candidates = [
|
|
675
|
+
let candidates = [
|
|
676
|
+
(tables.identity_head_fn)(aggregate_name),
|
|
677
|
+
(tables.reference_key_fn)(aggregate_name),
|
|
678
|
+
];
|
|
494
679
|
let aggregate_id = candidates
|
|
495
680
|
.into_iter()
|
|
496
681
|
.flatten()
|
|
497
|
-
.find_map(|key|
|
|
498
|
-
|
|
499
|
-
|
|
682
|
+
.find_map(|key| {
|
|
683
|
+
pairs
|
|
684
|
+
.iter()
|
|
685
|
+
.find(|(k, _)| k == key)
|
|
686
|
+
.and_then(|(_, v)| resolved_id_component(v))
|
|
687
|
+
})
|
|
688
|
+
.or_else(|| {
|
|
689
|
+
(event.aggregate == aggregate_name && !event.id.is_empty()).then(|| event.id.clone())
|
|
690
|
+
});
|
|
691
|
+
let Some(aggregate_id) = aggregate_id else {
|
|
692
|
+
return routed;
|
|
693
|
+
};
|
|
500
694
|
|
|
501
695
|
let declared = (tables.command_attributes_fn)(target_verb);
|
|
502
|
-
let facts: Vec<(String, Json)> = pairs
|
|
696
|
+
let facts: Vec<(String, Json)> = pairs
|
|
697
|
+
.iter()
|
|
698
|
+
.filter(|(k, _)| declared.contains(&k.as_str()))
|
|
699
|
+
.cloned()
|
|
700
|
+
.collect();
|
|
503
701
|
Json::obj(vec![
|
|
504
|
-
(
|
|
702
|
+
(
|
|
703
|
+
"to",
|
|
704
|
+
Json::obj(vec![
|
|
705
|
+
("aggregate", Json::str(aggregate_id)),
|
|
706
|
+
("entities", Json::Array(vec![Json::str(entity_id)])),
|
|
707
|
+
]),
|
|
708
|
+
),
|
|
505
709
|
("with", Json::Object(facts)),
|
|
506
710
|
])
|
|
507
711
|
}
|
|
508
712
|
|
|
509
|
-
|
|
510
|
-
fn react_policies
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
continue;
|
|
529
|
-
}
|
|
530
|
-
if let Some(qualifier) = policy.event_qualifier {
|
|
531
|
-
if qualifier != emitting {
|
|
713
|
+
impl<S: AggregateScan> Cascade<'_, S> {
|
|
714
|
+
fn react_policies(&mut self, event: &Event, depth: usize) {
|
|
715
|
+
let tables = self.tables;
|
|
716
|
+
let emitting = event
|
|
717
|
+
.aggregate
|
|
718
|
+
.rsplit("::")
|
|
719
|
+
.next()
|
|
720
|
+
.unwrap_or(event.aggregate.as_str());
|
|
721
|
+
|
|
722
|
+
for policy in tables.policies {
|
|
723
|
+
if policy.event_name != event.name {
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if let Some(qualifier) = policy.event_qualifier {
|
|
727
|
+
if qualifier != emitting {
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if !where_holds(policy.where_expr, event) {
|
|
532
732
|
continue;
|
|
533
733
|
}
|
|
534
|
-
}
|
|
535
|
-
if !where_holds(policy.where_expr, event) {
|
|
536
|
-
continue;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
let record = |extra: Vec<(&str, Json)>| -> Json {
|
|
540
|
-
let mut fields = vec![
|
|
541
|
-
("policy", Json::str(policy.policy_name.to_string())),
|
|
542
|
-
("on", Json::str(event.name.clone())),
|
|
543
|
-
("trigger", Json::str(policy.target_verb.to_string())),
|
|
544
|
-
];
|
|
545
|
-
fields.extend(extra.into_iter().map(|(k, v)| (k, v)));
|
|
546
|
-
Json::obj(fields)
|
|
547
|
-
};
|
|
548
734
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
735
|
+
let record = |extra: Vec<(&str, Json)>| -> Json {
|
|
736
|
+
let mut fields = vec![
|
|
737
|
+
("policy", Json::str(policy.policy_name.to_string())),
|
|
738
|
+
("on", Json::str(event.name.clone())),
|
|
739
|
+
("trigger", Json::str(policy.target_verb.to_string())),
|
|
740
|
+
];
|
|
741
|
+
fields.extend(extra);
|
|
742
|
+
Json::obj(fields)
|
|
743
|
+
};
|
|
556
744
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
if let Some(for_each) = policy.for_each {
|
|
560
|
-
let Some(def) = named_query::find(tables.queries, for_each) else {
|
|
561
|
-
reaction_log.push(record(vec![
|
|
745
|
+
if depth + 1 >= MAX_REACTION_DEPTH {
|
|
746
|
+
self.reaction_log.push(record(vec![
|
|
562
747
|
("delivered", Json::Bool(false)),
|
|
563
|
-
(
|
|
748
|
+
(
|
|
749
|
+
"reason",
|
|
750
|
+
Json::str(format!("reaction depth {MAX_REACTION_DEPTH} reached")),
|
|
751
|
+
),
|
|
564
752
|
]));
|
|
565
753
|
continue;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
//
|
|
569
|
-
|
|
570
|
-
let
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
reaction_log.push(record(vec![
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// A fan-out dispatches once per row its query answers; the row id is merged into the
|
|
757
|
+
// source a `with:` projection reads from.
|
|
758
|
+
if let Some(for_each) = policy.for_each {
|
|
759
|
+
let Some(def) = named_query::find(tables.queries, for_each) else {
|
|
760
|
+
self.reaction_log.push(record(vec![
|
|
574
761
|
("delivered", Json::Bool(false)),
|
|
575
|
-
("reason", Json::str(
|
|
762
|
+
("reason", Json::str(format!("no query {for_each}"))),
|
|
576
763
|
]));
|
|
577
764
|
continue;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
let
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
other => other,
|
|
765
|
+
};
|
|
766
|
+
// The query reads the event, not the projection, with the emitting record's
|
|
767
|
+
// identity lent under its head. No caller role: the policy is system-triggered.
|
|
768
|
+
let query_args = for_each_query_args(def, event, &tables);
|
|
769
|
+
let rows = match named_query::run(self.store, def, &query_args, None) {
|
|
770
|
+
Ok(rows) => rows,
|
|
771
|
+
Err(refusal) => {
|
|
772
|
+
self.reaction_log.push(record(vec![
|
|
773
|
+
("delivered", Json::Bool(false)),
|
|
774
|
+
("reason", Json::str(refusal.to_string())),
|
|
775
|
+
]));
|
|
776
|
+
continue;
|
|
591
777
|
}
|
|
592
778
|
};
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
779
|
+
for (row_id, row) in rows {
|
|
780
|
+
let row_record = |extra: Vec<(&str, Json)>| -> Json {
|
|
781
|
+
let mut fields = vec![("for_row", Json::str(row_id.clone()))];
|
|
782
|
+
fields.extend(extra);
|
|
783
|
+
let base = record(vec![]);
|
|
784
|
+
match base {
|
|
785
|
+
Json::Object(mut pairs) => {
|
|
786
|
+
pairs.extend(fields.into_iter().map(|(k, v)| (k.to_string(), v)));
|
|
787
|
+
Json::Object(pairs)
|
|
788
|
+
}
|
|
789
|
+
other => other,
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
let extra = policy.for_each_key.map(|key| (key, row_id.as_str()));
|
|
793
|
+
let args = trigger_args_with_row(
|
|
794
|
+
policy,
|
|
795
|
+
event,
|
|
796
|
+
extra,
|
|
797
|
+
Some((row_id.as_str(), &row)),
|
|
798
|
+
policy.target_verb,
|
|
799
|
+
&tables,
|
|
800
|
+
);
|
|
801
|
+
let outcome = self.run(policy.target_verb, &args, None, None, None, depth + 1);
|
|
802
|
+
match outcome {
|
|
803
|
+
Ok(()) => self
|
|
804
|
+
.reaction_log
|
|
805
|
+
.push(row_record(vec![("delivered", Json::Bool(true))])),
|
|
806
|
+
Err(refusal) => self.reaction_log.push(row_record(vec![
|
|
807
|
+
("delivered", Json::Bool(false)),
|
|
808
|
+
("reason", Json::str(refusal.to_string())),
|
|
809
|
+
])),
|
|
810
|
+
}
|
|
605
811
|
}
|
|
812
|
+
continue;
|
|
606
813
|
}
|
|
607
|
-
continue;
|
|
608
|
-
}
|
|
609
814
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
815
|
+
// No `with:`: the whole payload forwards verbatim. Reactions are system-triggered, so
|
|
816
|
+
// no caller role, and only saga legs stamp a correlation.
|
|
817
|
+
let args = trigger_args(policy, event, None, policy.target_verb, &tables);
|
|
818
|
+
let outcome = self.run(policy.target_verb, &args, None, None, None, depth + 1);
|
|
819
|
+
match outcome {
|
|
820
|
+
Ok(()) => self
|
|
821
|
+
.reaction_log
|
|
822
|
+
.push(record(vec![("delivered", Json::Bool(true))])),
|
|
823
|
+
// A refusal is recorded, not fatal to the emitting command. A panic is not
|
|
824
|
+
// caught, on purpose.
|
|
825
|
+
Err(refusal) => self.reaction_log.push(record(vec![
|
|
826
|
+
("delivered", Json::Bool(false)),
|
|
827
|
+
("reason", Json::str(refusal.to_string())),
|
|
828
|
+
])),
|
|
829
|
+
}
|
|
625
830
|
}
|
|
626
|
-
}
|
|
627
831
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
continue;
|
|
633
|
-
}
|
|
634
|
-
if let Some(qualifier) = policy.event_qualifier {
|
|
635
|
-
if qualifier != emitting {
|
|
832
|
+
// Cross-domain matches are recorded for the host to deliver, never dispatched or logged
|
|
833
|
+
// here: the delivery outcome does not exist yet.
|
|
834
|
+
for policy in tables.cross_domain_policies {
|
|
835
|
+
if policy.event_name != event.name {
|
|
636
836
|
continue;
|
|
637
837
|
}
|
|
838
|
+
if let Some(qualifier) = policy.event_qualifier {
|
|
839
|
+
if qualifier != emitting {
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if !where_holds(policy.where_expr, event) {
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
self.cross_domain.push(PendingCrossDomainReaction {
|
|
847
|
+
policy_name: policy.policy_name.to_string(),
|
|
848
|
+
event_name: event.name.clone(),
|
|
849
|
+
target_domain: policy.target_domain.to_string(),
|
|
850
|
+
target_verb: policy.target_verb.to_string(),
|
|
851
|
+
payload: event.payload.clone(),
|
|
852
|
+
});
|
|
638
853
|
}
|
|
639
|
-
if !where_holds(policy.where_expr, event) {
|
|
640
|
-
continue;
|
|
641
|
-
}
|
|
642
|
-
cross_domain.push(PendingCrossDomainReaction {
|
|
643
|
-
policy_name: policy.policy_name.to_string(),
|
|
644
|
-
event_name: event.name.clone(),
|
|
645
|
-
target_domain: policy.target_domain.to_string(),
|
|
646
|
-
target_verb: policy.target_verb.to_string(),
|
|
647
|
-
payload: event.payload.clone(),
|
|
648
|
-
});
|
|
649
854
|
}
|
|
650
855
|
}
|
|
651
856
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
857
|
+
impl<S: AggregateScan> Cascade<'_, S> {
|
|
858
|
+
// Silent when `event.name != pm.starts_on` or the instance already exists.
|
|
859
|
+
fn begin_saga(&mut self, event: &Event) {
|
|
860
|
+
let tables = self.tables;
|
|
861
|
+
for pm in tables.process_managers {
|
|
862
|
+
if event.name != pm.starts_on {
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
let Some(correlation) = correlation_of(pm, event, tables.reference_key_fn) else {
|
|
867
|
+
self.saga_log.push(Json::obj(vec![
|
|
868
|
+
("process_manager", Json::str(pm.name.to_string())),
|
|
869
|
+
("on", Json::str(event.name.clone())),
|
|
870
|
+
("born", Json::Bool(false)),
|
|
871
|
+
(
|
|
872
|
+
"reason",
|
|
873
|
+
Json::str(format!("no {} in the payload", pm.correlates_by)),
|
|
874
|
+
),
|
|
875
|
+
]));
|
|
876
|
+
continue;
|
|
877
|
+
};
|
|
658
878
|
|
|
659
|
-
|
|
660
|
-
|
|
879
|
+
let key = (pm.name.to_string(), correlation.clone());
|
|
880
|
+
if self.sagas.contains_key(&key) {
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
self.sagas.insert(
|
|
885
|
+
key,
|
|
886
|
+
SagaInstance {
|
|
887
|
+
state: pm.initial_state.to_string(),
|
|
888
|
+
memory: event.payload.clone(),
|
|
889
|
+
completed_compensations: Vec::new(),
|
|
890
|
+
},
|
|
891
|
+
);
|
|
892
|
+
self.saga_log.push(Json::obj(vec![
|
|
661
893
|
("process_manager", Json::str(pm.name.to_string())),
|
|
662
894
|
("on", Json::str(event.name.clone())),
|
|
663
|
-
("
|
|
664
|
-
("
|
|
895
|
+
("instance", Json::str(correlation)),
|
|
896
|
+
("born", Json::Bool(true)),
|
|
897
|
+
("state", Json::str(pm.initial_state.to_string())),
|
|
665
898
|
]));
|
|
666
|
-
continue;
|
|
667
|
-
};
|
|
668
|
-
|
|
669
|
-
let key = (pm.name.to_string(), correlation.clone());
|
|
670
|
-
if sagas.contains_key(&key) {
|
|
671
|
-
continue;
|
|
672
899
|
}
|
|
673
|
-
|
|
674
|
-
sagas.insert(key, SagaInstance { state: pm.initial_state.to_string(), memory: event.payload.clone(), completed_compensations: Vec::new() });
|
|
675
|
-
saga_log.push(Json::obj(vec![
|
|
676
|
-
("process_manager", Json::str(pm.name.to_string())),
|
|
677
|
-
("on", Json::str(event.name.clone())),
|
|
678
|
-
("instance", Json::str(correlation)),
|
|
679
|
-
("born", Json::Bool(true)),
|
|
680
|
-
("state", Json::str(pm.initial_state.to_string())),
|
|
681
|
-
]));
|
|
682
900
|
}
|
|
683
|
-
}
|
|
684
901
|
|
|
685
|
-
// Silent when no handler answers the event or correlation resolves to nothing.
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
)
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
902
|
+
// Silent when no handler answers the event or correlation resolves to nothing.
|
|
903
|
+
fn advance_saga(&mut self, event: &Event, depth: usize) {
|
|
904
|
+
let tables = self.tables;
|
|
905
|
+
for pm in tables.process_managers {
|
|
906
|
+
if !pm.handlers.iter().any(|h| h.event_type == event.name) {
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
let Some(correlation) = correlation_of(pm, event, tables.reference_key_fn) else {
|
|
910
|
+
continue;
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
let key = (pm.name.to_string(), correlation.clone());
|
|
914
|
+
let record = |extra: Vec<(&str, Json)>| -> Json {
|
|
915
|
+
let mut fields = vec![
|
|
916
|
+
("process_manager", Json::str(pm.name.to_string())),
|
|
917
|
+
("on", Json::str(event.name.clone())),
|
|
918
|
+
("instance", Json::str(correlation.clone())),
|
|
919
|
+
];
|
|
920
|
+
fields.extend(extra);
|
|
921
|
+
Json::obj(fields)
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
let Some(state) = self.sagas.get(&key).map(|instance| instance.state.clone()) else {
|
|
925
|
+
self.saga_log.push(record(vec![
|
|
926
|
+
("advanced", Json::Bool(false)),
|
|
927
|
+
(
|
|
928
|
+
"reason",
|
|
929
|
+
Json::str(format!("no conversation remembers {correlation:?}")),
|
|
930
|
+
),
|
|
931
|
+
]));
|
|
932
|
+
continue;
|
|
933
|
+
};
|
|
934
|
+
// The leg is chosen by (event, current state); build refuses two on one pair.
|
|
935
|
+
let Some(handler) = select_leg(pm, &event.name, &state) else {
|
|
936
|
+
self.saga_log.push(record(vec![
|
|
937
|
+
("advanced", Json::Bool(false)),
|
|
938
|
+
("reason", Json::str(leg_mismatch(pm, &event.name, &state))),
|
|
939
|
+
]));
|
|
940
|
+
continue;
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
if let Some(slot) = self.sagas.get_mut(&key) {
|
|
944
|
+
slot.state = handler.to_state.to_string();
|
|
945
|
+
}
|
|
946
|
+
self.saga_log.push(record(vec![
|
|
947
|
+
("advanced", Json::Bool(true)),
|
|
948
|
+
("from", Json::str(handler.from_state.to_string())),
|
|
949
|
+
("to", Json::str(handler.to_state.to_string())),
|
|
950
|
+
]));
|
|
951
|
+
|
|
952
|
+
let memory = self
|
|
953
|
+
.sagas
|
|
954
|
+
.get(&key)
|
|
955
|
+
.map_or(Json::Null, |instance| instance.memory.clone());
|
|
956
|
+
// `event.aggregate` is `{domain}::{aggregate}`, so the domain is read from it.
|
|
957
|
+
let domain_name = event
|
|
958
|
+
.aggregate
|
|
959
|
+
.split("::")
|
|
960
|
+
.next()
|
|
961
|
+
.unwrap_or(&event.aggregate);
|
|
962
|
+
let leg = SagaLeg {
|
|
963
|
+
pm,
|
|
964
|
+
correlation: &correlation,
|
|
965
|
+
domain_name,
|
|
966
|
+
};
|
|
967
|
+
let mut refused = false;
|
|
968
|
+
for spec in handler.dispatches {
|
|
969
|
+
let args = build_dispatch_args(
|
|
970
|
+
pm,
|
|
971
|
+
spec,
|
|
972
|
+
event,
|
|
973
|
+
&correlation,
|
|
974
|
+
&memory,
|
|
975
|
+
domain_name,
|
|
976
|
+
&tables,
|
|
977
|
+
);
|
|
978
|
+
// Resolved before the forward dispatch: the ledger entry is pushed speculatively.
|
|
979
|
+
let compensation_args = spec.compensates.map(|r| {
|
|
980
|
+
build_dispatch_args(pm, r, event, &correlation, &memory, domain_name, &tables)
|
|
981
|
+
});
|
|
982
|
+
let delivered =
|
|
983
|
+
self.deliver_saga_dispatch(&leg, spec, &args, depth, compensation_args);
|
|
984
|
+
if delivered == Some(false) {
|
|
985
|
+
refused = true;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if refused {
|
|
990
|
+
self.compensate(&leg, event, &memory, depth);
|
|
991
|
+
}
|
|
704
992
|
}
|
|
705
|
-
|
|
993
|
+
}
|
|
706
994
|
|
|
707
|
-
|
|
995
|
+
// Returns `Some(delivered)`, or `None` when the depth ceiling stopped the leg before it tried.
|
|
996
|
+
// The compensation is pushed before the forward dispatch runs: a nested refusal re-entering
|
|
997
|
+
// this saga must already see it. It is popped again if this leg's own attempt refuses.
|
|
998
|
+
fn deliver_saga_dispatch(
|
|
999
|
+
&mut self,
|
|
1000
|
+
leg: &SagaLeg,
|
|
1001
|
+
spec: &DispatchSpec,
|
|
1002
|
+
args: &Json,
|
|
1003
|
+
depth: usize,
|
|
1004
|
+
compensation_args: Option<Json>,
|
|
1005
|
+
) -> Option<bool> {
|
|
1006
|
+
// Ruby logs the unqualified name and qualifies only at dispatch; this mirrors that split.
|
|
708
1007
|
let record = |extra: Vec<(&str, Json)>| -> Json {
|
|
709
1008
|
let mut fields = vec![
|
|
710
|
-
("process_manager", Json::str(pm.name.to_string())),
|
|
711
|
-
("
|
|
712
|
-
("
|
|
1009
|
+
("process_manager", Json::str(leg.pm.name.to_string())),
|
|
1010
|
+
("instance", Json::str(leg.correlation.to_string())),
|
|
1011
|
+
("dispatch", Json::str(spec.command_name.to_string())),
|
|
713
1012
|
];
|
|
714
|
-
fields.extend(extra
|
|
1013
|
+
fields.extend(extra);
|
|
715
1014
|
Json::obj(fields)
|
|
716
1015
|
};
|
|
717
1016
|
|
|
718
|
-
|
|
719
|
-
saga_log.push(record(vec![
|
|
720
|
-
("
|
|
721
|
-
(
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
// The leg is chosen by (event, current state); build refuses two on one pair.
|
|
726
|
-
let Some(handler) = select_leg(pm, &event.name, &state) else {
|
|
727
|
-
saga_log.push(record(vec![
|
|
728
|
-
("advanced", Json::Bool(false)),
|
|
729
|
-
("reason", Json::str(leg_mismatch(pm, &event.name, &state))),
|
|
1017
|
+
if depth + 1 >= MAX_REACTION_DEPTH {
|
|
1018
|
+
self.saga_log.push(record(vec![
|
|
1019
|
+
("delivered", Json::Bool(false)),
|
|
1020
|
+
(
|
|
1021
|
+
"reason",
|
|
1022
|
+
Json::str(format!("reaction depth {MAX_REACTION_DEPTH} reached")),
|
|
1023
|
+
),
|
|
730
1024
|
]));
|
|
731
|
-
|
|
732
|
-
};
|
|
733
|
-
|
|
734
|
-
if let Some(slot) = sagas.get_mut(&key) {
|
|
735
|
-
slot.state = handler.to_state.to_string();
|
|
1025
|
+
return None;
|
|
736
1026
|
}
|
|
737
|
-
saga_log.push(record(vec![
|
|
738
|
-
("advanced", Json::Bool(true)),
|
|
739
|
-
("from", Json::str(handler.from_state.to_string())),
|
|
740
|
-
("to", Json::str(handler.to_state.to_string())),
|
|
741
|
-
]));
|
|
742
1027
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
let
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
if delivered == Some(false) {
|
|
756
|
-
refused = true;
|
|
1028
|
+
// `spec.command_name` is bare on the wire, so it is qualified before `dispatch_by_name`.
|
|
1029
|
+
let qualified = qualify_saga_command_name(leg.domain_name, spec.command_name);
|
|
1030
|
+
|
|
1031
|
+
let key = leg.key();
|
|
1032
|
+
let mut compensation_recorded = false;
|
|
1033
|
+
if let (Some(compensates_spec), Some(r_args)) = (spec.compensates, compensation_args) {
|
|
1034
|
+
if let Some(slot) = self.sagas.get_mut(&key) {
|
|
1035
|
+
slot.completed_compensations.push(CompletedCompensation {
|
|
1036
|
+
command_name: compensates_spec.command_name.to_string(),
|
|
1037
|
+
args: r_args,
|
|
1038
|
+
});
|
|
1039
|
+
compensation_recorded = true;
|
|
757
1040
|
}
|
|
758
1041
|
}
|
|
759
1042
|
|
|
760
|
-
|
|
761
|
-
|
|
1043
|
+
// System-triggered, so no caller role; `Some(stamp)` stamps every event this dispatch
|
|
1044
|
+
// produces.
|
|
1045
|
+
let outcome = self.run(&qualified, args, None, None, Some(&leg.stamp()), depth + 1);
|
|
1046
|
+
match outcome {
|
|
1047
|
+
Ok(()) => {
|
|
1048
|
+
self.saga_log
|
|
1049
|
+
.push(record(vec![("delivered", Json::Bool(true))]));
|
|
1050
|
+
Some(true)
|
|
1051
|
+
}
|
|
1052
|
+
Err(refusal) => {
|
|
1053
|
+
// This leg failed, so its speculative entry was never earned. `pop` is safe: any
|
|
1054
|
+
// entry pushed after it by a nested call was already drained by `compensate`.
|
|
1055
|
+
if compensation_recorded {
|
|
1056
|
+
if let Some(slot) = self.sagas.get_mut(&key) {
|
|
1057
|
+
slot.completed_compensations.pop();
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
self.saga_log.push(record(vec![
|
|
1061
|
+
("delivered", Json::Bool(false)),
|
|
1062
|
+
("reason", Json::str(refusal.to_string())),
|
|
1063
|
+
]));
|
|
1064
|
+
Some(false)
|
|
1065
|
+
}
|
|
762
1066
|
}
|
|
763
1067
|
}
|
|
764
|
-
}
|
|
765
1068
|
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
cross_domain: &mut Vec<PendingCrossDomainReaction>,
|
|
785
|
-
reaction_log: &mut Vec<Json>,
|
|
786
|
-
saga_log: &mut Vec<Json>,
|
|
787
|
-
stamp: &HashMap<String, String>,
|
|
788
|
-
compensation_args: Option<Json>,
|
|
789
|
-
) -> Option<bool> {
|
|
790
|
-
// Ruby logs the unqualified name and qualifies only at dispatch; this mirrors that split.
|
|
791
|
-
let record = |extra: Vec<(&str, Json)>| -> Json {
|
|
792
|
-
let mut fields = vec![
|
|
793
|
-
("process_manager", Json::str(pm.name.to_string())),
|
|
794
|
-
("instance", Json::str(correlation.to_string())),
|
|
795
|
-
("dispatch", Json::str(spec.command_name.to_string())),
|
|
796
|
-
];
|
|
797
|
-
fields.extend(extra.into_iter().map(|(k, v)| (k, v)));
|
|
798
|
-
Json::obj(fields)
|
|
799
|
-
};
|
|
800
|
-
|
|
801
|
-
if depth + 1 >= MAX_REACTION_DEPTH {
|
|
802
|
-
saga_log.push(record(vec![
|
|
803
|
-
("delivered", Json::Bool(false)),
|
|
804
|
-
("reason", Json::str(format!("reaction depth {MAX_REACTION_DEPTH} reached"))),
|
|
805
|
-
]));
|
|
806
|
-
return None;
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
// `spec.command_name` is bare on the wire, so it is qualified before `dispatch_by_name`.
|
|
810
|
-
let qualified = qualify_saga_command_name(domain_name, spec.command_name);
|
|
811
|
-
|
|
812
|
-
let key = (pm.name.to_string(), correlation.to_string());
|
|
813
|
-
let mut compensation_recorded = false;
|
|
814
|
-
if let (Some(compensates_spec), Some(r_args)) = (spec.compensates, compensation_args) {
|
|
815
|
-
if let Some(slot) = sagas.get_mut(&key) {
|
|
816
|
-
slot.completed_compensations.push(CompletedCompensation { command_name: compensates_spec.command_name.to_string(), args: r_args });
|
|
817
|
-
compensation_recorded = true;
|
|
818
|
-
}
|
|
819
|
-
}
|
|
1069
|
+
// Fires one ledger entry, newest-first. Args are pre-resolved, so `build_dispatch_args` is
|
|
1070
|
+
// skipped. A compensation that itself refuses is logged with `compensation_failed` and never
|
|
1071
|
+
// re-triggers `compensate`.
|
|
1072
|
+
fn deliver_derived_compensation(
|
|
1073
|
+
&mut self,
|
|
1074
|
+
leg: &SagaLeg,
|
|
1075
|
+
entry: &CompletedCompensation,
|
|
1076
|
+
depth: usize,
|
|
1077
|
+
) {
|
|
1078
|
+
let record = |extra: Vec<(&str, Json)>| -> Json {
|
|
1079
|
+
let mut fields = vec![
|
|
1080
|
+
("process_manager", Json::str(leg.pm.name.to_string())),
|
|
1081
|
+
("instance", Json::str(leg.correlation.to_string())),
|
|
1082
|
+
("dispatch", Json::str(entry.command_name.clone())),
|
|
1083
|
+
];
|
|
1084
|
+
fields.extend(extra);
|
|
1085
|
+
Json::obj(fields)
|
|
1086
|
+
};
|
|
820
1087
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
let outcome = orchestrate(
|
|
824
|
-
store, dispatch_fn, tables, sagas, &qualified, args, None, None, Some(stamp), occurred_at, depth + 1,
|
|
825
|
-
all_events, mutations, cross_domain, reaction_log, saga_log,
|
|
826
|
-
);
|
|
827
|
-
match outcome {
|
|
828
|
-
Ok(()) => {
|
|
829
|
-
saga_log.push(record(vec![("delivered", Json::Bool(true))]));
|
|
830
|
-
Some(true)
|
|
831
|
-
}
|
|
832
|
-
Err(refusal) => {
|
|
833
|
-
// This leg failed, so its speculative entry was never earned. `pop` is safe: any
|
|
834
|
-
// entry pushed after it by a nested call was already drained by `compensate`.
|
|
835
|
-
if compensation_recorded {
|
|
836
|
-
if let Some(slot) = sagas.get_mut(&key) {
|
|
837
|
-
slot.completed_compensations.pop();
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
saga_log.push(record(vec![
|
|
1088
|
+
if depth + 1 >= MAX_REACTION_DEPTH {
|
|
1089
|
+
self.saga_log.push(record(vec![
|
|
841
1090
|
("delivered", Json::Bool(false)),
|
|
842
|
-
(
|
|
1091
|
+
(
|
|
1092
|
+
"reason",
|
|
1093
|
+
Json::str(format!("reaction depth {MAX_REACTION_DEPTH} reached")),
|
|
1094
|
+
),
|
|
1095
|
+
("compensation", Json::Bool(true)),
|
|
1096
|
+
("compensation_failed", Json::Bool(true)),
|
|
843
1097
|
]));
|
|
844
|
-
|
|
1098
|
+
return;
|
|
845
1099
|
}
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
1100
|
|
|
849
|
-
//
|
|
850
|
-
|
|
851
|
-
// re-triggers `compensate`.
|
|
852
|
-
#[allow(clippy::too_many_arguments)]
|
|
853
|
-
fn deliver_derived_compensation<S: AggregateScan>(
|
|
854
|
-
store: &mut S,
|
|
855
|
-
dispatch_fn: fn(&mut S, &str, &Json, Option<&str>, Option<&str>, &mut Vec<MutationRecord>) -> Result<Vec<Event>, Refusal>,
|
|
856
|
-
tables: Tables<'static>,
|
|
857
|
-
sagas: &mut HashMap<(String, String), SagaInstance>,
|
|
858
|
-
pm: &ProcessManagerDef,
|
|
859
|
-
entry: &CompletedCompensation,
|
|
860
|
-
domain_name: &str,
|
|
861
|
-
correlation: &str,
|
|
862
|
-
occurred_at: Option<&str>,
|
|
863
|
-
depth: usize,
|
|
864
|
-
all_events: &mut Vec<Event>,
|
|
865
|
-
mutations: &mut Vec<MutationRecord>,
|
|
866
|
-
cross_domain: &mut Vec<PendingCrossDomainReaction>,
|
|
867
|
-
reaction_log: &mut Vec<Json>,
|
|
868
|
-
saga_log: &mut Vec<Json>,
|
|
869
|
-
) {
|
|
870
|
-
let record = |extra: Vec<(&str, Json)>| -> Json {
|
|
871
|
-
let mut fields = vec![
|
|
872
|
-
("process_manager", Json::str(pm.name.to_string())),
|
|
873
|
-
("instance", Json::str(correlation.to_string())),
|
|
874
|
-
("dispatch", Json::str(entry.command_name.to_string())),
|
|
875
|
-
];
|
|
876
|
-
fields.extend(extra.into_iter().map(|(k, v)| (k, v)));
|
|
877
|
-
Json::obj(fields)
|
|
878
|
-
};
|
|
1101
|
+
// A compensation's `command_name` is bare on the wire, like a forward dispatch's.
|
|
1102
|
+
let qualified = qualify_saga_command_name(leg.domain_name, &entry.command_name);
|
|
879
1103
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
(
|
|
886
|
-
|
|
887
|
-
|
|
1104
|
+
let outcome = self.run(
|
|
1105
|
+
&qualified,
|
|
1106
|
+
&entry.args,
|
|
1107
|
+
None,
|
|
1108
|
+
None,
|
|
1109
|
+
Some(&leg.stamp()),
|
|
1110
|
+
depth + 1,
|
|
1111
|
+
);
|
|
1112
|
+
match outcome {
|
|
1113
|
+
Ok(()) => self.saga_log.push(record(vec![
|
|
1114
|
+
("delivered", Json::Bool(true)),
|
|
1115
|
+
("compensation", Json::Bool(true)),
|
|
1116
|
+
])),
|
|
1117
|
+
Err(refusal) => self.saga_log.push(record(vec![
|
|
1118
|
+
("delivered", Json::Bool(false)),
|
|
1119
|
+
("reason", Json::str(refusal.to_string())),
|
|
1120
|
+
("compensation", Json::Bool(true)),
|
|
1121
|
+
("compensation_failed", Json::Bool(true)),
|
|
1122
|
+
])),
|
|
1123
|
+
}
|
|
888
1124
|
}
|
|
889
1125
|
|
|
890
|
-
//
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1126
|
+
// Silent when `event.name != pm.ends_on`, correlation is absent, or no instance existed.
|
|
1127
|
+
fn end_saga(&mut self, event: &Event) {
|
|
1128
|
+
let tables = self.tables;
|
|
1129
|
+
for pm in tables.process_managers {
|
|
1130
|
+
if event.name != pm.ends_on {
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
let Some(correlation) = correlation_of(pm, event, tables.reference_key_fn) else {
|
|
1134
|
+
continue;
|
|
1135
|
+
};
|
|
1136
|
+
let key = (pm.name.to_string(), correlation.clone());
|
|
1137
|
+
if self.sagas.remove(&key).is_none() {
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
894
1140
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
("delivered", Json::Bool(false)),
|
|
903
|
-
("reason", Json::str(refusal.to_string())),
|
|
904
|
-
("compensation", Json::Bool(true)),
|
|
905
|
-
("compensation_failed", Json::Bool(true)),
|
|
906
|
-
])),
|
|
1141
|
+
self.saga_log.push(Json::obj(vec![
|
|
1142
|
+
("process_manager", Json::str(pm.name.to_string())),
|
|
1143
|
+
("on", Json::str(event.name.clone())),
|
|
1144
|
+
("instance", Json::str(correlation)),
|
|
1145
|
+
("ended", Json::Bool(true)),
|
|
1146
|
+
]));
|
|
1147
|
+
}
|
|
907
1148
|
}
|
|
908
|
-
}
|
|
909
1149
|
|
|
910
|
-
//
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1150
|
+
// The `on :refused` leg, run once against the post-transition state. Its own dispatches never
|
|
1151
|
+
// compensate on failure.
|
|
1152
|
+
fn compensate(&mut self, leg: &SagaLeg, event: &Event, memory: &Json, depth: usize) {
|
|
1153
|
+
let (pm, correlation, domain_name) = (leg.pm, leg.correlation, leg.domain_name);
|
|
1154
|
+
let key = leg.key();
|
|
1155
|
+
let Some(current_state) = self.sagas.get(&key).map(|instance| instance.state.clone())
|
|
1156
|
+
else {
|
|
1157
|
+
return;
|
|
1158
|
+
};
|
|
1159
|
+
// A process manager with no compensating leg is silent; a state no leg answers from is
|
|
1160
|
+
// logged as `advanced: false`.
|
|
1161
|
+
if !pm.handlers.iter().any(|h| h.event_type == REFUSED) {
|
|
1162
|
+
return;
|
|
920
1163
|
}
|
|
921
1164
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1165
|
+
let record = |extra: Vec<(&str, Json)>| -> Json {
|
|
1166
|
+
let mut fields = vec![
|
|
1167
|
+
("process_manager", Json::str(pm.name.to_string())),
|
|
1168
|
+
("on", Json::str(REFUSED.to_string())),
|
|
1169
|
+
("instance", Json::str(correlation.to_string())),
|
|
1170
|
+
];
|
|
1171
|
+
fields.extend(extra);
|
|
1172
|
+
Json::obj(fields)
|
|
1173
|
+
};
|
|
930
1174
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
event: &Event,
|
|
942
|
-
correlation: &str,
|
|
943
|
-
memory: &Json,
|
|
944
|
-
occurred_at: Option<&str>,
|
|
945
|
-
depth: usize,
|
|
946
|
-
all_events: &mut Vec<Event>,
|
|
947
|
-
mutations: &mut Vec<MutationRecord>,
|
|
948
|
-
cross_domain: &mut Vec<PendingCrossDomainReaction>,
|
|
949
|
-
reaction_log: &mut Vec<Json>,
|
|
950
|
-
saga_log: &mut Vec<Json>,
|
|
951
|
-
) {
|
|
952
|
-
let Some(current_state) = sagas.get(key).map(|instance| instance.state.clone()) else { return };
|
|
953
|
-
// A process manager with no compensating leg is silent; a state no leg answers from is logged
|
|
954
|
-
// as `advanced: false`.
|
|
955
|
-
if !pm.handlers.iter().any(|h| h.event_type == REFUSED) {
|
|
956
|
-
return;
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
let record = |extra: Vec<(&str, Json)>| -> Json {
|
|
960
|
-
let mut fields = vec![
|
|
961
|
-
("process_manager", Json::str(pm.name.to_string())),
|
|
962
|
-
("on", Json::str(REFUSED.to_string())),
|
|
963
|
-
("instance", Json::str(correlation.to_string())),
|
|
964
|
-
];
|
|
965
|
-
fields.extend(extra.into_iter().map(|(k, v)| (k, v)));
|
|
966
|
-
Json::obj(fields)
|
|
967
|
-
};
|
|
1175
|
+
let Some(compensation) = select_leg(pm, REFUSED, ¤t_state) else {
|
|
1176
|
+
self.saga_log.push(record(vec![
|
|
1177
|
+
("advanced", Json::Bool(false)),
|
|
1178
|
+
(
|
|
1179
|
+
"reason",
|
|
1180
|
+
Json::str(leg_mismatch(pm, REFUSED, ¤t_state)),
|
|
1181
|
+
),
|
|
1182
|
+
]));
|
|
1183
|
+
return;
|
|
1184
|
+
};
|
|
968
1185
|
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1186
|
+
if let Some(slot) = self.sagas.get_mut(&key) {
|
|
1187
|
+
slot.state = compensation.to_state.to_string();
|
|
1188
|
+
}
|
|
1189
|
+
self.saga_log.push(record(vec![
|
|
1190
|
+
("advanced", Json::Bool(true)),
|
|
1191
|
+
("from", Json::str(compensation.from_state.to_string())),
|
|
1192
|
+
("to", Json::str(compensation.to_state.to_string())),
|
|
973
1193
|
]));
|
|
974
|
-
return;
|
|
975
|
-
};
|
|
976
|
-
|
|
977
|
-
if let Some(slot) = sagas.get_mut(key) {
|
|
978
|
-
slot.state = compensation.to_state.to_string();
|
|
979
|
-
}
|
|
980
|
-
saga_log.push(record(vec![
|
|
981
|
-
("advanced", Json::Bool(true)),
|
|
982
|
-
("from", Json::str(compensation.from_state.to_string())),
|
|
983
|
-
("to", Json::str(compensation.to_state.to_string())),
|
|
984
|
-
]));
|
|
985
|
-
|
|
986
|
-
let domain_name = event.aggregate.split("::").next().unwrap_or(&event.aggregate);
|
|
987
1194
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1195
|
+
// Derived compensations first, newest-first. The ledger is re-fetched each iteration, not
|
|
1196
|
+
// snapshotted: a nested reaction may push onto it mid-drain. If one removes the instance
|
|
1197
|
+
// mid-drain, the loop stops early.
|
|
1198
|
+
loop {
|
|
1199
|
+
let entry = match self.sagas.get_mut(&key) {
|
|
1200
|
+
Some(instance) => instance.completed_compensations.pop(),
|
|
1201
|
+
None => None,
|
|
1202
|
+
};
|
|
1203
|
+
let Some(entry) = entry else { break };
|
|
1204
|
+
self.deliver_derived_compensation(leg, &entry, depth);
|
|
1205
|
+
}
|
|
999
1206
|
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1207
|
+
let tables = self.tables;
|
|
1208
|
+
for spec in compensation.dispatches {
|
|
1209
|
+
let args =
|
|
1210
|
+
build_dispatch_args(pm, spec, event, correlation, memory, domain_name, &tables);
|
|
1211
|
+
let compensation_args = spec.compensates.map(|r| {
|
|
1212
|
+
build_dispatch_args(pm, r, event, correlation, memory, domain_name, &tables)
|
|
1213
|
+
});
|
|
1214
|
+
self.deliver_saga_dispatch(leg, spec, &args, depth, compensation_args);
|
|
1215
|
+
}
|
|
1007
1216
|
}
|
|
1008
1217
|
}
|
|
1009
1218
|
|
|
@@ -1067,10 +1276,16 @@ mod tests {
|
|
|
1067
1276
|
id: "thing-1".to_string(),
|
|
1068
1277
|
payload: Json::Object(vec![]),
|
|
1069
1278
|
occurred_at: None,
|
|
1070
|
-
correlation: Some([(
|
|
1279
|
+
correlation: Some(HashMap::from([(
|
|
1280
|
+
"reference".to_string(),
|
|
1281
|
+
"xfer-1".to_string(),
|
|
1282
|
+
)])),
|
|
1071
1283
|
};
|
|
1072
1284
|
|
|
1073
|
-
assert_eq!(
|
|
1285
|
+
assert_eq!(
|
|
1286
|
+
correlation_of(&pm, &event, no_reference_key),
|
|
1287
|
+
Some("xfer-1".to_string())
|
|
1288
|
+
);
|
|
1074
1289
|
}
|
|
1075
1290
|
|
|
1076
1291
|
#[test]
|
|
@@ -1080,12 +1295,24 @@ mod tests {
|
|
|
1080
1295
|
name: "SomeEvent".to_string(),
|
|
1081
1296
|
aggregate: "Domain::Thing".to_string(),
|
|
1082
1297
|
id: "thing-1".to_string(),
|
|
1083
|
-
payload: Json::Object(vec![(
|
|
1298
|
+
payload: Json::Object(vec![(
|
|
1299
|
+
"reference".to_string(),
|
|
1300
|
+
Json::Object(vec![(
|
|
1301
|
+
"value".to_string(),
|
|
1302
|
+
Json::Str("from-payload".to_string()),
|
|
1303
|
+
)]),
|
|
1304
|
+
)]),
|
|
1084
1305
|
occurred_at: None,
|
|
1085
|
-
correlation: Some(
|
|
1306
|
+
correlation: Some(HashMap::from([(
|
|
1307
|
+
"reference".to_string(),
|
|
1308
|
+
"from-stamp".to_string(),
|
|
1309
|
+
)])),
|
|
1086
1310
|
};
|
|
1087
1311
|
|
|
1088
|
-
assert_eq!(
|
|
1312
|
+
assert_eq!(
|
|
1313
|
+
correlation_of(&pm, &event, no_reference_key),
|
|
1314
|
+
Some("from-payload".to_string())
|
|
1315
|
+
);
|
|
1089
1316
|
}
|
|
1090
1317
|
|
|
1091
1318
|
#[test]
|
|
@@ -1097,7 +1324,7 @@ mod tests {
|
|
|
1097
1324
|
id: "thing-1".to_string(),
|
|
1098
1325
|
payload: Json::Object(vec![]),
|
|
1099
1326
|
occurred_at: None,
|
|
1100
|
-
correlation: Some([("reference".to_string(), String::new())]
|
|
1327
|
+
correlation: Some(HashMap::from([("reference".to_string(), String::new())])),
|
|
1101
1328
|
};
|
|
1102
1329
|
|
|
1103
1330
|
assert_eq!(correlation_of(&pm, &event, no_reference_key), None);
|
|
@@ -1106,19 +1333,21 @@ mod tests {
|
|
|
1106
1333
|
// The stamping step of `orchestrate`, exercised without a real `dispatch_fn`.
|
|
1107
1334
|
#[test]
|
|
1108
1335
|
fn stamping_merges_into_an_existing_correlation_map_rather_than_replacing_it() {
|
|
1109
|
-
let mut events = vec![
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
let stamp: HashMap<String, String> = [("reference".to_string(), "xfer-1".to_string())].into_iter().collect();
|
|
1336
|
+
let mut events = vec![Event {
|
|
1337
|
+
name: "E".to_string(),
|
|
1338
|
+
aggregate: "Domain::Thing".to_string(),
|
|
1339
|
+
id: "thing-1".to_string(),
|
|
1340
|
+
payload: Json::Null,
|
|
1341
|
+
occurred_at: None,
|
|
1342
|
+
correlation: Some(HashMap::from([("already".to_string(), "here".to_string())])),
|
|
1343
|
+
}];
|
|
1344
|
+
let stamp: HashMap<String, String> =
|
|
1345
|
+
HashMap::from([("reference".to_string(), "xfer-1".to_string())]);
|
|
1120
1346
|
for event in &mut events {
|
|
1121
|
-
event
|
|
1347
|
+
event
|
|
1348
|
+
.correlation
|
|
1349
|
+
.get_or_insert_with(HashMap::new)
|
|
1350
|
+
.extend(stamp.iter().map(|(k, v)| (k.clone(), v.clone())));
|
|
1122
1351
|
}
|
|
1123
1352
|
|
|
1124
1353
|
let correlation = events[0].correlation.as_ref().unwrap();
|
|
@@ -1130,8 +1359,22 @@ mod tests {
|
|
|
1130
1359
|
#[test]
|
|
1131
1360
|
fn occurred_at_stamps_every_event_the_same_host_supplied_moment() {
|
|
1132
1361
|
let mut events = vec![
|
|
1133
|
-
Event {
|
|
1134
|
-
|
|
1362
|
+
Event {
|
|
1363
|
+
name: "A".to_string(),
|
|
1364
|
+
aggregate: "Domain::Thing".to_string(),
|
|
1365
|
+
id: "t1".to_string(),
|
|
1366
|
+
payload: Json::Null,
|
|
1367
|
+
occurred_at: None,
|
|
1368
|
+
correlation: None,
|
|
1369
|
+
},
|
|
1370
|
+
Event {
|
|
1371
|
+
name: "B".to_string(),
|
|
1372
|
+
aggregate: "Domain::Thing".to_string(),
|
|
1373
|
+
id: "t2".to_string(),
|
|
1374
|
+
payload: Json::Null,
|
|
1375
|
+
occurred_at: None,
|
|
1376
|
+
correlation: None,
|
|
1377
|
+
},
|
|
1135
1378
|
];
|
|
1136
1379
|
let occurred_at = Some("2026-08-28T00:00:00Z");
|
|
1137
1380
|
|
|
@@ -1141,13 +1384,26 @@ mod tests {
|
|
|
1141
1384
|
}
|
|
1142
1385
|
}
|
|
1143
1386
|
|
|
1144
|
-
assert_eq!(
|
|
1145
|
-
|
|
1387
|
+
assert_eq!(
|
|
1388
|
+
events[0].occurred_at,
|
|
1389
|
+
Some("2026-08-28T00:00:00Z".to_string())
|
|
1390
|
+
);
|
|
1391
|
+
assert_eq!(
|
|
1392
|
+
events[1].occurred_at,
|
|
1393
|
+
Some("2026-08-28T00:00:00Z".to_string())
|
|
1394
|
+
);
|
|
1146
1395
|
}
|
|
1147
1396
|
|
|
1148
1397
|
#[test]
|
|
1149
1398
|
fn occurred_at_leaves_events_unstamped_when_no_caller_supplied_one() {
|
|
1150
|
-
let mut events = vec![Event {
|
|
1399
|
+
let mut events = vec![Event {
|
|
1400
|
+
name: "A".to_string(),
|
|
1401
|
+
aggregate: "Domain::Thing".to_string(),
|
|
1402
|
+
id: "t1".to_string(),
|
|
1403
|
+
payload: Json::Null,
|
|
1404
|
+
occurred_at: None,
|
|
1405
|
+
correlation: None,
|
|
1406
|
+
}];
|
|
1151
1407
|
let occurred_at: Option<&str> = None;
|
|
1152
1408
|
|
|
1153
1409
|
if let Some(ts) = occurred_at {
|
|
@@ -1215,32 +1471,58 @@ mod tests {
|
|
|
1215
1471
|
}
|
|
1216
1472
|
|
|
1217
1473
|
#[test]
|
|
1218
|
-
fn multi_leg_reentrant_saga_fires_completed_compensations_newest_first_on_a_later_legs_refusal()
|
|
1474
|
+
fn multi_leg_reentrant_saga_fires_completed_compensations_newest_first_on_a_later_legs_refusal()
|
|
1475
|
+
{
|
|
1219
1476
|
// `DISPATCH_RA`/`RB` are named statics; the forward legs are inline literals because a
|
|
1220
1477
|
// static's value cannot be moved into an array (`DispatchSpec` is not `Copy`).
|
|
1221
|
-
static DISPATCH_RA: DispatchSpec = DispatchSpec {
|
|
1222
|
-
|
|
1478
|
+
static DISPATCH_RA: DispatchSpec = DispatchSpec {
|
|
1479
|
+
command_name: "RA",
|
|
1480
|
+
with: &[],
|
|
1481
|
+
compensates: None,
|
|
1482
|
+
};
|
|
1483
|
+
static DISPATCH_RB: DispatchSpec = DispatchSpec {
|
|
1484
|
+
command_name: "RB",
|
|
1485
|
+
with: &[],
|
|
1486
|
+
compensates: None,
|
|
1487
|
+
};
|
|
1223
1488
|
|
|
1224
1489
|
static HANDLERS: &[Handler] = &[
|
|
1225
1490
|
Handler {
|
|
1226
1491
|
event_type: "Started",
|
|
1227
1492
|
from_state: "start",
|
|
1228
1493
|
to_state: "state1",
|
|
1229
|
-
dispatches: &[DispatchSpec {
|
|
1494
|
+
dispatches: &[DispatchSpec {
|
|
1495
|
+
command_name: "A",
|
|
1496
|
+
with: &[],
|
|
1497
|
+
compensates: Some(&DISPATCH_RA),
|
|
1498
|
+
}],
|
|
1230
1499
|
},
|
|
1231
1500
|
Handler {
|
|
1232
1501
|
event_type: "AEvent",
|
|
1233
1502
|
from_state: "state1",
|
|
1234
1503
|
to_state: "state2",
|
|
1235
|
-
dispatches: &[DispatchSpec {
|
|
1504
|
+
dispatches: &[DispatchSpec {
|
|
1505
|
+
command_name: "B",
|
|
1506
|
+
with: &[],
|
|
1507
|
+
compensates: Some(&DISPATCH_RB),
|
|
1508
|
+
}],
|
|
1236
1509
|
},
|
|
1237
1510
|
Handler {
|
|
1238
1511
|
event_type: "BEvent",
|
|
1239
1512
|
from_state: "state2",
|
|
1240
1513
|
to_state: "state3",
|
|
1241
|
-
dispatches: &[DispatchSpec {
|
|
1514
|
+
dispatches: &[DispatchSpec {
|
|
1515
|
+
command_name: "C",
|
|
1516
|
+
with: &[],
|
|
1517
|
+
compensates: None,
|
|
1518
|
+
}],
|
|
1519
|
+
},
|
|
1520
|
+
Handler {
|
|
1521
|
+
event_type: REFUSED,
|
|
1522
|
+
from_state: "state3",
|
|
1523
|
+
to_state: "compensated",
|
|
1524
|
+
dispatches: &[],
|
|
1242
1525
|
},
|
|
1243
|
-
Handler { event_type: REFUSED, from_state: "state3", to_state: "compensated", dispatches: &[] },
|
|
1244
1526
|
];
|
|
1245
1527
|
|
|
1246
1528
|
static PROCESS_MANAGERS: &[ProcessManagerDef] = &[ProcessManagerDef {
|
|
@@ -1293,12 +1575,23 @@ mod tests {
|
|
|
1293
1575
|
&mut reaction_log,
|
|
1294
1576
|
&mut saga_log,
|
|
1295
1577
|
);
|
|
1296
|
-
assert!(
|
|
1578
|
+
assert!(
|
|
1579
|
+
outcome.is_ok(),
|
|
1580
|
+
"top-level Kickoff should not itself refuse: {outcome:?}"
|
|
1581
|
+
);
|
|
1297
1582
|
|
|
1298
1583
|
let key = ("TestMultiLeg".to_string(), "corr-1".to_string());
|
|
1299
|
-
let instance = sagas
|
|
1300
|
-
|
|
1301
|
-
|
|
1584
|
+
let instance = sagas
|
|
1585
|
+
.get(&key)
|
|
1586
|
+
.expect("the saga instance should still exist (it never reaches ends_on)");
|
|
1587
|
+
assert_eq!(
|
|
1588
|
+
instance.state, "compensated",
|
|
1589
|
+
"the saga should have unwound to the on-:refused leg's own to_state"
|
|
1590
|
+
);
|
|
1591
|
+
assert!(
|
|
1592
|
+
instance.completed_compensations.is_empty(),
|
|
1593
|
+
"the ledger should be fully drained after compensate runs"
|
|
1594
|
+
);
|
|
1302
1595
|
|
|
1303
1596
|
let dispatch_entries: Vec<(String, bool, bool)> = saga_log
|
|
1304
1597
|
.iter()
|
|
@@ -1389,13 +1682,19 @@ mod tests {
|
|
|
1389
1682
|
// gates see.
|
|
1390
1683
|
#[test]
|
|
1391
1684
|
fn a_needed_fact_is_answered_before_the_absent_argument_gate_and_a_supplied_one_is_kept() {
|
|
1392
|
-
let table =
|
|
1685
|
+
let table =
|
|
1686
|
+
Json::parse(r#"{"T::Slot.Lease": [{"fact": "now", "type": "Integer"}]}"#).unwrap();
|
|
1393
1687
|
crate::kernel::needs::install(Some(&table));
|
|
1394
1688
|
crate::kernel::needs::fix_clock(Some(77));
|
|
1395
1689
|
|
|
1396
1690
|
let args = Json::obj(vec![("with", Json::obj(vec![("holder", Json::str("a"))]))]);
|
|
1397
1691
|
assert!(run_strict_now("T::Slot.Lease", &args, None).is_ok());
|
|
1398
|
-
SEEN.with(|s|
|
|
1692
|
+
SEEN.with(|s| {
|
|
1693
|
+
assert_eq!(
|
|
1694
|
+
s.borrow().as_ref().unwrap().get("now"),
|
|
1695
|
+
Some(&Json::int(77))
|
|
1696
|
+
);
|
|
1697
|
+
});
|
|
1399
1698
|
|
|
1400
1699
|
let supplied = Json::obj(vec![("with", Json::obj(vec![("now", Json::int(5))]))]);
|
|
1401
1700
|
assert!(run_strict_now("T::Slot.Lease", &supplied, None).is_ok());
|
|
@@ -1411,6 +1710,7 @@ mod tests {
|
|
|
1411
1710
|
// Pins domain-qualifying a same-domain entity command (`Manifest::Slot.Fill`): its leftover
|
|
1412
1711
|
// `::` looks cross-domain, and an unqualified name would fail as an unknown command. The fake
|
|
1413
1712
|
// dispatch panics on any verb but the qualified, dot-joined form.
|
|
1713
|
+
#[allow(clippy::unnecessary_wraps)] // reason: the signature is the `DispatchFn` type.
|
|
1414
1714
|
fn entity_command_test_dispatch(
|
|
1415
1715
|
_store: &mut MultiLegTestStore,
|
|
1416
1716
|
verb: &str,
|
|
@@ -1438,21 +1738,25 @@ mod tests {
|
|
|
1438
1738
|
}]),
|
|
1439
1739
|
// The qualified, dot-joined shape a generated `registry.rs` arm uses.
|
|
1440
1740
|
"Test::Manifest.Slot.Fill" => Ok(vec![plain_event("SlotFilled")]),
|
|
1441
|
-
other =>
|
|
1741
|
+
other => {
|
|
1742
|
+
panic!("unexpected verb in entity-command qualification test (BUG#9): {other}")
|
|
1743
|
+
}
|
|
1442
1744
|
}
|
|
1443
1745
|
}
|
|
1444
1746
|
|
|
1445
1747
|
#[test]
|
|
1446
1748
|
fn a_saga_leg_dispatching_a_same_domain_entity_command_is_domain_qualified_and_dot_folded() {
|
|
1447
|
-
static HANDLERS: &[Handler] = &[
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1749
|
+
static HANDLERS: &[Handler] = &[Handler {
|
|
1750
|
+
event_type: "Started",
|
|
1751
|
+
from_state: "start",
|
|
1752
|
+
to_state: "filling",
|
|
1753
|
+
// Bare on the wire, entity-nested.
|
|
1754
|
+
dispatches: &[DispatchSpec {
|
|
1755
|
+
command_name: "Manifest::Slot.Fill",
|
|
1756
|
+
with: &[],
|
|
1757
|
+
compensates: None,
|
|
1758
|
+
}],
|
|
1759
|
+
}];
|
|
1456
1760
|
|
|
1457
1761
|
static PROCESS_MANAGERS: &[ProcessManagerDef] = &[ProcessManagerDef {
|
|
1458
1762
|
name: "TestPacking",
|
|
@@ -1504,7 +1808,10 @@ mod tests {
|
|
|
1504
1808
|
&mut reaction_log,
|
|
1505
1809
|
&mut saga_log,
|
|
1506
1810
|
);
|
|
1507
|
-
assert!(
|
|
1811
|
+
assert!(
|
|
1812
|
+
outcome.is_ok(),
|
|
1813
|
+
"top-level Kickoff should not itself refuse: {outcome:?}"
|
|
1814
|
+
);
|
|
1508
1815
|
|
|
1509
1816
|
let dispatch_entries: Vec<(String, bool)> = saga_log
|
|
1510
1817
|
.iter()
|
|
@@ -1526,11 +1833,23 @@ mod tests {
|
|
|
1526
1833
|
|
|
1527
1834
|
#[test]
|
|
1528
1835
|
fn qualify_saga_command_name_folds_a_same_domain_entity_command_reference() {
|
|
1529
|
-
assert_eq!(
|
|
1530
|
-
|
|
1836
|
+
assert_eq!(
|
|
1837
|
+
qualify_saga_command_name("Waybill", "Manifest::Slot.Fill"),
|
|
1838
|
+
"Waybill::Manifest.Slot.Fill"
|
|
1839
|
+
);
|
|
1840
|
+
assert_eq!(
|
|
1841
|
+
qualify_saga_command_name("Waybill", "Manifest::Slot.Clear"),
|
|
1842
|
+
"Waybill::Manifest.Slot.Clear"
|
|
1843
|
+
);
|
|
1531
1844
|
// A plain aggregate command is only domain-prefixed.
|
|
1532
|
-
assert_eq!(
|
|
1533
|
-
|
|
1845
|
+
assert_eq!(
|
|
1846
|
+
qualify_saga_command_name("Waybill", "Manifest.Open"),
|
|
1847
|
+
"Waybill::Manifest.Open"
|
|
1848
|
+
);
|
|
1849
|
+
assert_eq!(
|
|
1850
|
+
qualify_saga_command_name("Waybill", "Manifest.AddSlot"),
|
|
1851
|
+
"Waybill::Manifest.AddSlot"
|
|
1852
|
+
);
|
|
1534
1853
|
}
|
|
1535
1854
|
|
|
1536
1855
|
// Fixture: non-creating `Widgets::Ledger.Grant`, identity head `email`, declared attribute
|
|
@@ -1569,38 +1888,67 @@ mod tests {
|
|
|
1569
1888
|
}
|
|
1570
1889
|
|
|
1571
1890
|
fn routed_to_a_receiver(id: &str) -> Json {
|
|
1572
|
-
Json::obj(vec![
|
|
1891
|
+
Json::obj(vec![
|
|
1892
|
+
("to", Json::str(id)),
|
|
1893
|
+
("with", Json::obj(vec![("role", Json::str("Admin"))])),
|
|
1894
|
+
])
|
|
1573
1895
|
}
|
|
1574
1896
|
|
|
1575
1897
|
#[test]
|
|
1576
1898
|
fn split_routed_args_routes_a_value_object_identity_as_the_receiver() {
|
|
1577
1899
|
// A value-object identity is `{"value": ...}` in an event payload and forwards as that
|
|
1578
1900
|
// object.
|
|
1579
|
-
let projected = Json::obj(vec![
|
|
1901
|
+
let projected = Json::obj(vec![
|
|
1902
|
+
("email", Json::obj(vec![("value", Json::str("a@b.co"))])),
|
|
1903
|
+
("role", Json::str("Admin")),
|
|
1904
|
+
]);
|
|
1580
1905
|
|
|
1581
|
-
let routed =
|
|
1906
|
+
let routed =
|
|
1907
|
+
split_routed_args(projected, "Widgets::Ledger.Grant", &routed_fixture_tables());
|
|
1582
1908
|
|
|
1583
|
-
assert_eq!(
|
|
1909
|
+
assert_eq!(
|
|
1910
|
+
routed,
|
|
1911
|
+
routed_to_a_receiver("a@b.co"),
|
|
1912
|
+
"a VO identity should route as `to` — got {routed:?}"
|
|
1913
|
+
);
|
|
1584
1914
|
}
|
|
1585
1915
|
|
|
1586
1916
|
#[test]
|
|
1587
1917
|
fn split_routed_args_still_routes_a_bare_scalar_identity() {
|
|
1588
|
-
let projected = Json::obj(vec![
|
|
1918
|
+
let projected = Json::obj(vec![
|
|
1919
|
+
("email", Json::str("a@b.co")),
|
|
1920
|
+
("role", Json::str("Admin")),
|
|
1921
|
+
]);
|
|
1589
1922
|
|
|
1590
|
-
let routed =
|
|
1923
|
+
let routed =
|
|
1924
|
+
split_routed_args(projected, "Widgets::Ledger.Grant", &routed_fixture_tables());
|
|
1591
1925
|
|
|
1592
|
-
assert_eq!(
|
|
1926
|
+
assert_eq!(
|
|
1927
|
+
routed,
|
|
1928
|
+
routed_to_a_receiver("a@b.co"),
|
|
1929
|
+
"a scalar identity should route as `to` — got {routed:?}"
|
|
1930
|
+
);
|
|
1593
1931
|
}
|
|
1594
1932
|
|
|
1595
1933
|
#[test]
|
|
1596
1934
|
fn split_routed_args_leaves_the_args_flat_when_the_identity_is_empty_or_not_a_value() {
|
|
1597
1935
|
let flat = Json::obj(vec![("role", Json::str("Admin"))]);
|
|
1598
|
-
let empty = Json::obj(vec![
|
|
1599
|
-
|
|
1936
|
+
let empty = Json::obj(vec![
|
|
1937
|
+
("email", Json::obj(vec![("value", Json::str(""))])),
|
|
1938
|
+
("role", Json::str("Admin")),
|
|
1939
|
+
]);
|
|
1940
|
+
let unrelated = Json::obj(vec![
|
|
1941
|
+
("email", Json::obj(vec![("other", Json::str("a@b.co"))])),
|
|
1942
|
+
("role", Json::str("Admin")),
|
|
1943
|
+
]);
|
|
1600
1944
|
|
|
1601
1945
|
for projected in [empty, unrelated] {
|
|
1602
|
-
let routed =
|
|
1603
|
-
|
|
1946
|
+
let routed =
|
|
1947
|
+
split_routed_args(projected, "Widgets::Ledger.Grant", &routed_fixture_tables());
|
|
1948
|
+
assert_eq!(
|
|
1949
|
+
routed, flat,
|
|
1950
|
+
"an unusable identity should leave only the declared facts — got {routed:?}"
|
|
1951
|
+
);
|
|
1604
1952
|
}
|
|
1605
1953
|
}
|
|
1606
1954
|
|
|
@@ -1612,7 +1960,10 @@ mod tests {
|
|
|
1612
1960
|
);
|
|
1613
1961
|
assert_eq!(entity_command_paths("Waybill::Manifest.AddSlot"), None);
|
|
1614
1962
|
// A two-level entity command is deliberately not routed.
|
|
1615
|
-
assert_eq!(
|
|
1963
|
+
assert_eq!(
|
|
1964
|
+
entity_command_paths("Domain::Workspace.Board.Card.Annotate"),
|
|
1965
|
+
None
|
|
1966
|
+
);
|
|
1616
1967
|
}
|
|
1617
1968
|
|
|
1618
1969
|
// Fixture: one-level entity command `Widgets::Crate.Slot.Fill` whose `with:` carries the
|
|
@@ -1651,11 +2002,15 @@ mod tests {
|
|
|
1651
2002
|
}
|
|
1652
2003
|
|
|
1653
2004
|
#[test]
|
|
1654
|
-
fn route_dispatch_args_threads_the_triggering_events_aggregate_identity_into_a_one_level_entity_commands_route(
|
|
2005
|
+
fn route_dispatch_args_threads_the_triggering_events_aggregate_identity_into_a_one_level_entity_commands_route(
|
|
2006
|
+
) {
|
|
1655
2007
|
// `number` is VO-nested; the parent aggregate's identity comes only from the triggering
|
|
1656
2008
|
// event.
|
|
1657
2009
|
let tables = bug10_fixture_tables();
|
|
1658
|
-
let projected = Json::obj(vec![
|
|
2010
|
+
let projected = Json::obj(vec![
|
|
2011
|
+
("number", Json::obj(vec![("value", Json::int(7))])),
|
|
2012
|
+
("item", Json::str("hello")),
|
|
2013
|
+
]);
|
|
1659
2014
|
let event = Event {
|
|
1660
2015
|
name: "SlotAdded".to_string(),
|
|
1661
2016
|
aggregate: "Widgets::Crate".to_string(),
|
|
@@ -1665,7 +2020,7 @@ mod tests {
|
|
|
1665
2020
|
correlation: None,
|
|
1666
2021
|
};
|
|
1667
2022
|
|
|
1668
|
-
let routed = route_dispatch_args(projected, "Widgets::Crate.Slot.Fill", &tables, &event);
|
|
2023
|
+
let routed = route_dispatch_args(&projected, "Widgets::Crate.Slot.Fill", &tables, &event);
|
|
1669
2024
|
assert_eq!(
|
|
1670
2025
|
routed,
|
|
1671
2026
|
Json::obj(vec![
|
|
@@ -1679,11 +2034,15 @@ mod tests {
|
|
|
1679
2034
|
}
|
|
1680
2035
|
|
|
1681
2036
|
#[test]
|
|
1682
|
-
fn route_dispatch_args_falls_through_unchanged_when_the_triggering_event_is_a_different_aggregate(
|
|
2037
|
+
fn route_dispatch_args_falls_through_unchanged_when_the_triggering_event_is_a_different_aggregate(
|
|
2038
|
+
) {
|
|
1683
2039
|
// The triggering event is a different aggregate, so no receiver may be invented: falls
|
|
1684
2040
|
// through unrouted.
|
|
1685
2041
|
let tables = bug10_fixture_tables();
|
|
1686
|
-
let projected = Json::obj(vec![
|
|
2042
|
+
let projected = Json::obj(vec![
|
|
2043
|
+
("number", Json::obj(vec![("value", Json::int(7))])),
|
|
2044
|
+
("item", Json::str("hello")),
|
|
2045
|
+
]);
|
|
1687
2046
|
let event = Event {
|
|
1688
2047
|
name: "SomethingElseHappened".to_string(),
|
|
1689
2048
|
aggregate: "Widgets::OtherThing".to_string(),
|
|
@@ -1693,7 +2052,7 @@ mod tests {
|
|
|
1693
2052
|
correlation: None,
|
|
1694
2053
|
};
|
|
1695
2054
|
|
|
1696
|
-
let routed = route_dispatch_args(projected, "Widgets::Crate.Slot.Fill", &tables, &event);
|
|
2055
|
+
let routed = route_dispatch_args(&projected, "Widgets::Crate.Slot.Fill", &tables, &event);
|
|
1697
2056
|
assert_eq!(
|
|
1698
2057
|
routed,
|
|
1699
2058
|
Json::obj(vec![("item", Json::str("hello"))]),
|
|
@@ -1706,6 +2065,7 @@ mod tests {
|
|
|
1706
2065
|
|
|
1707
2066
|
// Two legs on one event from different states, each reached exactly when its `from:` is
|
|
1708
2067
|
// current.
|
|
2068
|
+
#[allow(clippy::unnecessary_wraps)] // reason: the signature is the `DispatchFn` type.
|
|
1709
2069
|
fn two_leg_test_dispatch(
|
|
1710
2070
|
_store: &mut MultiLegTestStore,
|
|
1711
2071
|
verb: &str,
|
|
@@ -1730,14 +2090,24 @@ mod tests {
|
|
|
1730
2090
|
}
|
|
1731
2091
|
|
|
1732
2092
|
#[test]
|
|
1733
|
-
fn a_saga_leg_is_selected_by_event_and_current_state_so_two_legs_on_one_event_are_both_reachable(
|
|
2093
|
+
fn a_saga_leg_is_selected_by_event_and_current_state_so_two_legs_on_one_event_are_both_reachable(
|
|
2094
|
+
) {
|
|
1734
2095
|
static HANDLERS: &[Handler] = &[
|
|
1735
|
-
Handler {
|
|
2096
|
+
Handler {
|
|
2097
|
+
event_type: "Scanned",
|
|
2098
|
+
from_state: "posted",
|
|
2099
|
+
to_state: "picked_up",
|
|
2100
|
+
dispatches: &[],
|
|
2101
|
+
},
|
|
1736
2102
|
Handler {
|
|
1737
2103
|
event_type: "Scanned",
|
|
1738
2104
|
from_state: "picked_up",
|
|
1739
2105
|
to_state: "handed_over",
|
|
1740
|
-
dispatches: &[DispatchSpec {
|
|
2106
|
+
dispatches: &[DispatchSpec {
|
|
2107
|
+
command_name: "Deliver",
|
|
2108
|
+
with: &[],
|
|
2109
|
+
compensates: None,
|
|
2110
|
+
}],
|
|
1741
2111
|
},
|
|
1742
2112
|
];
|
|
1743
2113
|
static PROCESS_MANAGERS: &[ProcessManagerDef] = &[ProcessManagerDef {
|
|
@@ -1799,8 +2169,16 @@ mod tests {
|
|
|
1799
2169
|
return None;
|
|
1800
2170
|
}
|
|
1801
2171
|
Some((
|
|
1802
|
-
entry
|
|
1803
|
-
|
|
2172
|
+
entry
|
|
2173
|
+
.get("from")
|
|
2174
|
+
.and_then(Json::as_str)
|
|
2175
|
+
.unwrap_or("")
|
|
2176
|
+
.to_string(),
|
|
2177
|
+
entry
|
|
2178
|
+
.get("to")
|
|
2179
|
+
.and_then(Json::as_str)
|
|
2180
|
+
.unwrap_or("")
|
|
2181
|
+
.to_string(),
|
|
1804
2182
|
))
|
|
1805
2183
|
})
|
|
1806
2184
|
.collect();
|
|
@@ -1821,6 +2199,7 @@ mod tests {
|
|
|
1821
2199
|
|
|
1822
2200
|
// One dispatch announces two events: a saga leg answers the first, a policy the second. The
|
|
1823
2201
|
// whole batch is logged before any reaction, then reactions run per event in emits order.
|
|
2202
|
+
#[allow(clippy::unnecessary_wraps)] // reason: the signature is the `DispatchFn` type.
|
|
1824
2203
|
fn two_event_test_dispatch(
|
|
1825
2204
|
_store: &mut MultiLegTestStore,
|
|
1826
2205
|
verb: &str,
|
|
@@ -1852,7 +2231,11 @@ mod tests {
|
|
|
1852
2231
|
event_type: "Legged",
|
|
1853
2232
|
from_state: "started",
|
|
1854
2233
|
to_state: "logged",
|
|
1855
|
-
dispatches: &[DispatchSpec {
|
|
2234
|
+
dispatches: &[DispatchSpec {
|
|
2235
|
+
command_name: "Log",
|
|
2236
|
+
with: &[],
|
|
2237
|
+
compensates: None,
|
|
2238
|
+
}],
|
|
1856
2239
|
}];
|
|
1857
2240
|
static PROCESS_MANAGERS: &[ProcessManagerDef] = &[ProcessManagerDef {
|
|
1858
2241
|
name: "Route",
|
|
@@ -1929,8 +2312,18 @@ mod tests {
|
|
|
1929
2312
|
#[test]
|
|
1930
2313
|
fn a_leg_miss_names_every_from_state_the_event_could_have_answered_from() {
|
|
1931
2314
|
static HANDLERS: &[Handler] = &[
|
|
1932
|
-
Handler {
|
|
1933
|
-
|
|
2315
|
+
Handler {
|
|
2316
|
+
event_type: "Scanned",
|
|
2317
|
+
from_state: "posted",
|
|
2318
|
+
to_state: "picked_up",
|
|
2319
|
+
dispatches: &[],
|
|
2320
|
+
},
|
|
2321
|
+
Handler {
|
|
2322
|
+
event_type: "Scanned",
|
|
2323
|
+
from_state: "picked_up",
|
|
2324
|
+
to_state: "handed_over",
|
|
2325
|
+
dispatches: &[],
|
|
2326
|
+
},
|
|
1934
2327
|
];
|
|
1935
2328
|
let pm = ProcessManagerDef {
|
|
1936
2329
|
name: "Delivery",
|
|
@@ -1940,8 +2333,14 @@ mod tests {
|
|
|
1940
2333
|
initial_state: "posted",
|
|
1941
2334
|
handlers: HANDLERS,
|
|
1942
2335
|
};
|
|
1943
|
-
assert_eq!(
|
|
2336
|
+
assert_eq!(
|
|
2337
|
+
select_leg(&pm, "Scanned", "picked_up").map(|h| h.to_state),
|
|
2338
|
+
Some("handed_over")
|
|
2339
|
+
);
|
|
1944
2340
|
assert!(select_leg(&pm, "Scanned", "handed_over").is_none());
|
|
1945
|
-
assert_eq!(
|
|
2341
|
+
assert_eq!(
|
|
2342
|
+
leg_mismatch(&pm, "Scanned", "handed_over"),
|
|
2343
|
+
"in \"handed_over\", not \"posted\" or \"picked_up\""
|
|
2344
|
+
);
|
|
1946
2345
|
}
|
|
1947
2346
|
}
|