hecks 2.8.0 → 3.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/exe/hecks +50 -7
- data/lib/hecks/adapters/driven/claude_code.rb +4 -104
- data/lib/hecks/adapters/driven/d1.rb +44 -237
- data/lib/hecks/adapters/driven/folder.rb +17 -76
- data/lib/hecks/adapters/driven/google_authentication.rb +15 -49
- data/lib/hecks/adapters/driven/governance_authorization.rb +23 -70
- data/lib/hecks/adapters/driven/heki/journal.rb +8 -41
- data/lib/hecks/adapters/driven/heki/saga_store.rb +12 -54
- data/lib/hecks/adapters/driven/heki/snapshot.rb +4 -12
- data/lib/hecks/adapters/driven/heki.rb +13 -42
- data/lib/hecks/adapters/driven/identity_registry.rb +3 -14
- data/lib/hecks/adapters/driven/in_memory_ordering.rb +9 -26
- data/lib/hecks/adapters/driven/in_process_key_vault.rb +2 -14
- data/lib/hecks/adapters/driven/lambda/client.rb +10 -51
- data/lib/hecks/adapters/driven/lambda.rb +10 -84
- data/lib/hecks/adapters/driven/local_storage.rb +5 -40
- data/lib/hecks/adapters/driven/memory.rb +32 -100
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +5 -21
- data/lib/hecks/adapters/driven/postgres/codec.rb +6 -28
- data/lib/hecks/adapters/driven/postgres/outbox.rb +3 -12
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +10 -32
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +34 -108
- data/lib/hecks/adapters/driven/postgres/shared_connection.rb +180 -0
- data/lib/hecks/adapters/driven/postgres.rb +149 -136
- data/lib/hecks/adapters/driven/postgres_era.adapter +4 -15
- data/lib/hecks/adapters/driven/prism.rb +48 -38
- data/lib/hecks/adapters/driven/sql_query_builder.rb +21 -121
- data/lib/hecks/adapters/driven/sqlite/codec.rb +8 -40
- data/lib/hecks/adapters/driven/sqlite/projection.rb +26 -82
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +40 -84
- data/lib/hecks/adapters/driven/sqlite.rb +128 -65
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +76 -62
- data/lib/hecks/adapters/driven.rb +5 -36
- data/lib/hecks/adapters/driving/github_webhook.rb +8 -76
- data/lib/hecks/behaviors/dsl.rb +7 -25
- data/lib/hecks/behaviors/expectations.rb +32 -142
- data/lib/hecks/behaviors/ir.rb +7 -20
- data/lib/hecks/behaviors/rspec.rb +6 -17
- data/lib/hecks/behaviors/runner.rb +10 -34
- data/lib/hecks/behaviors.rb +4 -8
- data/lib/hecks/bench/cli.rb +62 -0
- data/lib/hecks/bench/environment.rb +71 -0
- data/lib/hecks/bench/postgres_probe.rb +30 -0
- data/lib/hecks/bench/report.rb +76 -0
- data/lib/hecks/bench/ruby_runner.rb +93 -0
- data/lib/hecks/bench/run.rb +55 -0
- data/lib/hecks/bench/rust_runner.rb +101 -0
- data/lib/hecks/bench/stats.rb +46 -0
- data/lib/hecks/bench/suite.rb +98 -0
- data/lib/hecks/bench/workload.rb +89 -0
- data/lib/hecks/bench.rb +17 -0
- data/lib/hecks/bluebook/aggregate.rb +9 -75
- data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +9 -38
- data/lib/hecks/bluebook/assembly/build.rb +4 -15
- data/lib/hecks/bluebook/assembly/contract.rb +15 -136
- data/lib/hecks/bluebook/assembly/contracts.rb +56 -174
- data/lib/hecks/bluebook/assembly/marks.rb +31 -223
- data/lib/hecks/bluebook/assembly/specializer.rb +6 -52
- data/lib/hecks/bluebook/assembly.rb +6 -45
- data/lib/hecks/bluebook/attribute.rb +12 -61
- data/lib/hecks/bluebook/behaviour/aggregate.rb +39 -39
- data/lib/hecks/bluebook/behaviour/attribute.rb +3 -14
- data/lib/hecks/bluebook/behaviour/chapter.rb +14 -42
- data/lib/hecks/bluebook/behaviour/command.rb +14 -66
- data/lib/hecks/bluebook/behaviour/domain_port.rb +11 -20
- data/lib/hecks/bluebook/behaviour/entity.rb +7 -27
- data/lib/hecks/bluebook/behaviour/hexagon.rb +23 -41
- data/lib/hecks/bluebook/behaviour/lifecycle.rb +15 -40
- data/lib/hecks/bluebook/behaviour/policy.rb +23 -52
- data/lib/hecks/bluebook/behaviour/process_manager.rb +30 -57
- data/lib/hecks/bluebook/behaviour/read_model.rb +25 -31
- data/lib/hecks/bluebook/behaviour/traits.rb +27 -64
- data/lib/hecks/bluebook/behaviour/value_object.rb +5 -24
- data/lib/hecks/bluebook/capabilities.rb +17 -49
- data/lib/hecks/bluebook/chapter.rb +16 -50
- data/lib/hecks/bluebook/command.rb +23 -81
- data/lib/hecks/bluebook/domain_port.rb +49 -67
- data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +50 -229
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +50 -377
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +33 -204
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +16 -69
- data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +96 -708
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +63 -169
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +3 -1
- data/lib/hecks/bluebook/dsl/command_builder.rb +74 -546
- data/lib/hecks/bluebook/dsl/const_shim.rb +8 -41
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +42 -89
- data/lib/hecks/bluebook/dsl/entity_builder.rb +44 -330
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +20 -269
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +148 -197
- data/lib/hecks/bluebook/dsl/identity_declaration.rb +13 -88
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +6 -20
- data/lib/hecks/bluebook/dsl/policy_builder.rb +16 -88
- data/lib/hecks/bluebook/dsl/port_builder.rb +2 -6
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +22 -96
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +31 -184
- data/lib/hecks/bluebook/dsl/query_builder.rb +38 -43
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +75 -201
- data/lib/hecks/bluebook/dsl/rule_reference.rb +22 -163
- data/lib/hecks/bluebook/dsl/translation_builder.rb +28 -114
- data/lib/hecks/bluebook/dsl/value_object_builder.rb +10 -124
- data/lib/hecks/bluebook/dsl/word_gate.rb +16 -162
- data/lib/hecks/bluebook/dsl/world_builder.rb +46 -111
- data/lib/hecks/bluebook/entity.rb +13 -71
- data/lib/hecks/bluebook/expression/ast_json.rb +24 -190
- data/lib/hecks/bluebook/expression/ast_reader.rb +12 -54
- data/lib/hecks/bluebook/expression/canonical_form.rb +11 -88
- data/lib/hecks/bluebook/expression/evaluator.rb +47 -295
- data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +30 -147
- data/lib/hecks/bluebook/expression/resolver.rb +39 -661
- data/lib/hecks/bluebook/hexagon.rb +18 -33
- data/lib/hecks/bluebook/lifecycle.rb +5 -16
- data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +1 -7
- data/lib/hecks/bluebook/meta_validator/judge.rb +111 -361
- data/lib/hecks/bluebook/meta_validator/plan.rb +33 -162
- data/lib/hecks/bluebook/meta_validator/port_judge.rb +2 -7
- data/lib/hecks/bluebook/meta_validator/readings.rb +56 -342
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +25 -176
- data/lib/hecks/bluebook/meta_validator/shapes.rb +53 -267
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +47 -306
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +7 -43
- data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +246 -0
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +6 -18
- data/lib/hecks/bluebook/meta_validator.rb +87 -352
- data/lib/hecks/bluebook/model_check/client_profile.rb +6 -21
- data/lib/hecks/bluebook/model_check.rb +122 -596
- data/lib/hecks/bluebook/pattern_subset.rb +49 -69
- data/lib/hecks/bluebook/process_manager.rb +13 -83
- data/lib/hecks/bluebook/project_loader.rb +3 -39
- data/lib/hecks/bluebook/project_register.rb +12 -60
- data/lib/hecks/bluebook/query.rb +27 -51
- data/lib/hecks/bluebook/read_model.rb +57 -81
- data/lib/hecks/bluebook/reference.rb +7 -41
- data/lib/hecks/bluebook/smoke_test.rb +9 -99
- data/lib/hecks/bluebook/synthesizer.rb +13 -62
- data/lib/hecks/bluebook/translation.rb +26 -62
- data/lib/hecks/bluebook/value_object.rb +11 -36
- data/lib/hecks/bluebook.rb +5 -22
- data/lib/hecks/cache_dir.rb +4 -23
- data/lib/hecks/canonical_json.rb +30 -0
- data/lib/hecks/chapters.rb +102 -0
- data/lib/hecks/cli/behaviors.rb +61 -0
- data/lib/hecks/cli/canonicalise.rb +25 -0
- data/lib/hecks/cli/check_engine_agreement.rb +32 -0
- data/lib/hecks/cli/check_era.rb +88 -0
- data/lib/hecks/cli/console.rb +66 -0
- data/lib/hecks/cli/deploy_template_diff.rb +40 -0
- data/lib/hecks/cli/doc_coverage.rb +32 -0
- data/lib/hecks/cli/document.rb +5 -23
- data/lib/hecks/cli/expression_projection.rb +41 -0
- data/lib/hecks/cli/follow.rb +88 -0
- data/lib/hecks/cli/history.rb +44 -0
- data/lib/hecks/cli/ir.rb +4 -6
- data/lib/hecks/cli/mcp.rb +5 -6
- data/lib/hecks/cli/model_check.rb +24 -88
- data/lib/hecks/cli/pattern_cases.rb +47 -0
- data/lib/hecks/cli/present.rb +39 -0
- data/lib/hecks/cli/project_cli.rb +223 -64
- data/lib/hecks/cli/project_diagrams.rb +4 -10
- data/lib/hecks/cli/project_glossary.rb +44 -0
- data/lib/hecks/cli/reference.rb +46 -0
- data/lib/hecks/cli/refresh_projections.rb +44 -0
- data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +150 -0
- data/lib/hecks/cli/release.rb +70 -0
- data/lib/hecks/cli/release_gem.rb +59 -0
- data/lib/hecks/cli/rspec_io_parallel_files.rb +137 -0
- data/lib/hecks/cli/rspec_shard_files.rb +66 -0
- data/lib/hecks/cli/run.rb +15 -61
- data/lib/hecks/cli/seed_semantics_corpus.rb +71 -0
- data/lib/hecks/cli/serve_query_ir_mcp.rb +30 -0
- data/lib/hecks/cli/shape.rb +70 -0
- data/lib/hecks/cli/smoke_http.rb +230 -0
- data/lib/hecks/cli/smoke_test.rb +7 -19
- data/lib/hecks/cli/spec_example.rb +27 -0
- data/lib/hecks/cli/statements.rb +32 -0
- data/lib/hecks/cli/stores.rb +4 -6
- data/lib/hecks/cli/stress_concurrency_specs.rb +177 -0
- data/lib/hecks/cli.rb +216 -73
- data/lib/hecks/codemod.rb +324 -0
- data/lib/hecks/construct.rb +8 -34
- data/lib/hecks/corpus.rb +423 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +617 -158
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +41 -11
- data/lib/hecks/deploy/oidc.json +82 -2
- data/lib/hecks/doc/reference.rb +351 -0
- data/lib/hecks/doors/cli_door.rb +196 -0
- data/lib/hecks/doors/cli_runner.rb +297 -0
- data/lib/hecks/{facade → doors}/command_request.rb +14 -34
- data/lib/hecks/doors/handle.rb +206 -0
- data/lib/hecks/doors/json_door.rb +132 -0
- data/lib/hecks/doors/launcher_options.rb +129 -0
- data/lib/hecks/{cli → doors}/mcp_door.rb +25 -120
- data/lib/hecks/doors/mcp_door_scope.rb +97 -0
- data/lib/hecks/doors/ruby_door/aggregate_door.rb +162 -0
- data/lib/hecks/doors/ruby_door/chapter.rb +72 -0
- data/lib/hecks/doors/ruby_door.rb +77 -0
- data/lib/hecks/doors.rb +61 -0
- data/lib/hecks/embryonaut_bluebook/lock.rb +3 -35
- data/lib/hecks/embryonaut_bluebook/shape.rb +1 -17
- data/lib/hecks/embryonaut_bluebook/vendor.rb +8 -53
- data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +4 -19
- data/lib/hecks/embryonaut_bluebook.rb +5 -72
- data/lib/hecks/engine_agreement.rb +156 -0
- data/lib/hecks/facade.rb +23 -51
- data/lib/hecks/forms/app.rb +21 -76
- data/lib/hecks/forms/banking_presentation.rb +50 -0
- data/lib/hecks/forms/command_form_renderer.rb +10 -29
- data/lib/hecks/forms/field_renderer.rb +17 -156
- data/lib/hecks/forms/field_shape.rb +31 -222
- data/lib/hecks/forms/html.rb +12 -47
- data/lib/hecks/forms/index_renderer.rb +1 -4
- data/lib/hecks/forms/page.rb +3 -9
- data/lib/hecks/forms/params.rb +14 -60
- data/lib/hecks/forms/port_argument.rb +2 -19
- data/lib/hecks/forms/query_form_renderer.rb +4 -79
- data/lib/hecks/forms/record_renderer.rb +8 -75
- data/lib/hecks/forms/record_table.rb +7 -50
- data/lib/hecks/forms/reference_options.rb +3 -30
- data/lib/hecks/forms/value_object_shape.rb +8 -35
- data/lib/hecks/forms.rb +5 -33
- data/lib/hecks/fqn.rb +5 -12
- data/lib/hecks/framework/bluebook/compliance.bluebook +16 -65
- data/lib/hecks/framework/bluebook/console_settings.bluebook +47 -134
- data/lib/hecks/framework/bluebook/framework.hecksagon +5 -14
- data/lib/hecks/framework/bluebook/governance.bluebook +13 -34
- data/lib/hecks/framework/bluebook/identity.bluebook +11 -26
- data/lib/hecks/framework/bluebook/privacy.bluebook +8 -33
- data/lib/hecks/framework.rb +11 -80
- data/lib/hecks/freezer.rb +5 -39
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +393 -0
- data/lib/hecks/fuzzing/combination_miner.rb +86 -0
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +214 -0
- data/lib/hecks/fuzzing/coverage_campaign.rb +143 -0
- data/lib/hecks/fuzzing/differential.rb +156 -0
- data/lib/hecks/fuzzing/domain_generator.rb +615 -0
- data/lib/hecks/fuzzing/era_boundary.rb +71 -0
- data/lib/hecks/fuzzing/form_census.rb +136 -0
- data/lib/hecks/fuzzing/generated_domain_check.rb +93 -0
- data/lib/hecks/fuzzing/invalid_value_generator.rb +64 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +301 -0
- data/lib/hecks/fuzzing/nondeterministic.rb +51 -0
- data/lib/hecks/fuzzing/outside_world.rb +45 -0
- data/lib/hecks/fuzzing/persistence_parity.rb +102 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +88 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +314 -0
- data/lib/hecks/fuzzing/properties/guards.rb +190 -0
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +300 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +93 -0
- data/lib/hecks/fuzzing/properties/outbox.rb +89 -0
- data/lib/hecks/fuzzing/properties/querying.rb +175 -0
- data/lib/hecks/fuzzing/properties.rb +162 -0
- data/lib/hecks/fuzzing/qa_settings.rb +123 -0
- data/lib/hecks/fuzzing/replay.rb +505 -0
- data/lib/hecks/fuzzing/rotation_priority.rb +48 -0
- data/lib/hecks/fuzzing/rust_gap_manifest.rb +108 -0
- data/lib/hecks/fuzzing/self_consistency.rb +339 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +385 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +132 -0
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +95 -0
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +77 -0
- data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +109 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +237 -0
- data/lib/hecks/fuzzing/sequence_generator.rb +189 -0
- data/lib/hecks/fuzzing/shrinker.rb +166 -0
- data/lib/hecks/fuzzing/structural_skips.rb +33 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +43 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +163 -0
- data/lib/hecks/fuzzing/value_generator.rb +216 -0
- data/lib/hecks/fuzzing.rb +23 -0
- data/lib/hecks/grammar/evolve.rb +336 -0
- data/lib/hecks/grammar/expression.bluebook +15 -40
- data/lib/hecks/grammar/translation.bluebook +3 -6
- data/lib/hecks/grammar.rb +16 -41
- data/lib/hecks/hecks/adapters/codebase/codemods.rb +45 -0
- data/lib/hecks/hecks/adapters/codebase/conformance.rb +71 -0
- data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +202 -0
- data/lib/hecks/hecks/adapters/codebase/gem_registry.rb +80 -0
- data/lib/hecks/hecks/adapters/codebase/kernel_tables.rb +54 -0
- data/lib/hecks/hecks/adapters/codebase/language.rb +240 -0
- data/lib/hecks/hecks/adapters/codebase/npm_registry.rb +102 -0
- data/lib/hecks/hecks/adapters/codebase/publishing.rb +142 -0
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +67 -0
- data/lib/hecks/hecks/adapters/codebase/release_facts.rb +90 -0
- data/lib/hecks/hecks/adapters/codebase/ruby_child.rb +83 -0
- data/lib/hecks/hecks/adapters/codebase/secret_vault.rb +36 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.adapter +7 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +189 -0
- data/lib/hecks/hecks/adapters/codebase/sqlite_fixture.rb +71 -0
- data/lib/hecks/hecks/adapters/codebase/style.rb +171 -0
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +55 -0
- data/lib/hecks/hecks/adapters/codebase/test_suite.rb +165 -0
- data/lib/hecks/hecks/adapters/codebase/tree.rb +111 -0
- data/lib/hecks/hecks/adapters/console_capture.rb +79 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +108 -0
- data/lib/hecks/hecks/adapters/git.adapter +7 -0
- data/lib/hecks/hecks/adapters/git.rb +102 -0
- data/lib/hecks/hecks/adapters/host_http.adapter +7 -0
- data/lib/hecks/hecks/adapters/host_http.rb +65 -0
- data/lib/hecks/hecks/adapters/in_process_boot.adapter +7 -0
- data/lib/hecks/hecks/adapters/in_process_boot.rb +197 -0
- data/lib/hecks/hecks/adapters/in_process_operations.rb +202 -0
- data/lib/hecks/hecks/adapters/journal_store/changes.rb +143 -0
- data/lib/hecks/hecks/adapters/journal_store/compaction.rb +145 -0
- data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +132 -0
- data/lib/hecks/hecks/adapters/journal_store/examination.rb +135 -0
- data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +163 -0
- data/lib/hecks/hecks/adapters/journal_store/readings.rb +187 -0
- data/lib/hecks/hecks/adapters/journal_store.adapter +7 -0
- data/lib/hecks/hecks/adapters/journal_store.rb +64 -0
- data/lib/hecks/hecks/adapters/local_files.adapter +7 -0
- data/lib/hecks/hecks/adapters/local_files.rb +46 -0
- data/lib/hecks/hecks/adapters/pg_admin.adapter +7 -0
- data/lib/hecks/hecks/adapters/pg_admin.rb +245 -0
- data/lib/hecks/hecks/adapters/process_pool.adapter +7 -0
- data/lib/hecks/hecks/adapters/process_pool.rb +119 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.rb +179 -0
- data/lib/hecks/hecks/adapters/rust_workspace.rb +150 -0
- data/lib/hecks/hecks/adapters/shell.adapter +7 -0
- data/lib/hecks/hecks/adapters/shell.rb +49 -0
- data/lib/hecks/hecks/adapters/terminal.adapter +7 -0
- data/lib/hecks/hecks/adapters/terminal.rb +74 -0
- data/lib/hecks/hecks/codebase.behaviors +172 -0
- data/lib/hecks/hecks/codebase.bluebook +2453 -0
- data/lib/hecks/hecks/context_map.hecksagon +13 -0
- data/lib/hecks/hecks/custodian.behaviors +179 -0
- data/lib/hecks/hecks/custodian.bluebook +1847 -0
- data/lib/hecks/hecks/deploy.behaviors +74 -0
- data/lib/hecks/hecks/deploy.world +12 -0
- data/lib/hecks/hecks/environments/memory.world +10 -0
- data/lib/hecks/hecks/governance.world +7 -0
- data/lib/hecks/hecks/hecks.behaviors +62 -0
- data/lib/hecks/hecks/hecks.bluebook +124 -0
- data/lib/hecks/hecks/hecks.hecksagon +390 -0
- data/lib/hecks/hecks/hecks.world +42 -0
- data/lib/hecks/ir.rb +14 -83
- data/lib/hecks/kernel_capabilities.rb +194 -0
- data/lib/hecks/language/adapter.bluebook +2 -7
- data/lib/hecks/language/bluebook/aggregate.bluebook +56 -221
- data/lib/hecks/language/bluebook/attaches/paging.bluebook +6 -15
- data/lib/hecks/language/bluebook/bluebook.bluebook +26 -83
- data/lib/hecks/language/bluebook/bluebook.hecksagon +3 -15
- data/lib/hecks/language/bluebook/command.bluebook +27 -144
- data/lib/hecks/language/bluebook/entity.bluebook +24 -97
- data/lib/hecks/language/bluebook/policy.bluebook +16 -67
- data/lib/hecks/language/bluebook/process_manager.bluebook +28 -151
- data/lib/hecks/language/bluebook/projection.bluebook +126 -60
- data/lib/hecks/language/bluebook/query.bluebook +19 -43
- data/lib/hecks/language/bluebook/shape.bluebook +23 -73
- data/lib/hecks/language/bluebook/syntax.bluebook +51 -262
- data/lib/hecks/language/bluebook/vocabulary.bluebook +83 -198
- data/lib/hecks/language/hecksagon/adapter_binding.bluebook +3 -10
- data/lib/hecks/language/hecksagon/domain_port.bluebook +14 -19
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +26 -25
- data/lib/hecks/language/hecksagon/port_operation.bluebook +3 -14
- data/lib/hecks/language/oidc.json +35 -0
- data/lib/hecks/language/port.bluebook +4 -17
- data/lib/hecks/language/translation/translation.bluebook +3 -9
- data/lib/hecks/language/translation/translation_aggregate.bluebook +5 -24
- data/lib/hecks/language/world/wiring.bluebook +1 -3
- data/lib/hecks/language/world/world.bluebook +5 -7
- data/lib/hecks/literal.rb +8 -50
- data/lib/hecks/mcp_stdio_guard.rb +4 -41
- data/lib/hecks/naming.rb +29 -137
- data/lib/hecks/persistence_legacy_fixture/regenerate.rb +161 -0
- data/lib/hecks/persistence_legacy_fixture.rb +113 -0
- data/lib/hecks/ports/access_control.rb +7 -23
- data/lib/hecks/ports/agent/answers.rb +5 -23
- data/lib/hecks/ports/agent.rb +47 -172
- data/lib/hecks/ports/authentication.rb +12 -39
- data/lib/hecks/ports/authorization.rb +16 -43
- data/lib/hecks/ports/clock.rb +5 -45
- data/lib/hecks/ports/extraction.rb +7 -12
- data/lib/hecks/ports/identity_assignment.rb +8 -29
- data/lib/hecks/ports/identity_generation.rb +6 -29
- data/lib/hecks/ports/identity_resolution.rb +2 -10
- data/lib/hecks/ports/key_vault.rb +9 -45
- data/lib/hecks/ports/persistence/append_only.rb +61 -64
- data/lib/hecks/ports/persistence/binding_policy.rb +4 -14
- data/lib/hecks/ports/persistence/codec_boundary.rb +20 -80
- data/lib/hecks/ports/persistence/null_saga_store.rb +4 -20
- data/lib/hecks/ports/persistence/plugin.rb +13 -42
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +44 -144
- data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +15 -115
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +20 -85
- data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +6 -25
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +18 -24
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +55 -181
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/events.rb +77 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +12 -122
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +28 -128
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +73 -474
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +31 -124
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +45 -219
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +13 -108
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +19 -76
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +9 -49
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +6 -123
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +23 -55
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -72
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +7 -16
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +43 -127
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +13 -45
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +131 -555
- data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +13 -63
- data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +227 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +2 -17
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +3 -14
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +13 -58
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -17
- data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +9 -26
- data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +50 -47
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +18 -124
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +34 -123
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +5 -12
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +3 -9
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -14
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +1 -0
- data/lib/hecks/ports/persistence/plugins/era.rb +4 -23
- data/lib/hecks/ports/persistence/postgres_dump/connection.rb +7 -23
- data/lib/hecks/ports/persistence/postgres_dump.rb +4 -35
- data/lib/hecks/ports/persistence/remote_runtime.rb +2 -24
- data/lib/hecks/ports/persistence/repository_factory.rb +8 -27
- data/lib/hecks/ports/persistence/state_codec.rb +47 -108
- data/lib/hecks/ports/persistence.rb +7 -14
- data/lib/hecks/ports/projection.rb +42 -84
- data/lib/hecks/ports/query/in_memory.rb +10 -58
- data/lib/hecks/ports/query/ordering.rb +11 -32
- data/lib/hecks/ports/query.rb +14 -37
- data/lib/hecks/ports.rb +2 -8
- data/lib/hecks/projection_files.rb +285 -0
- data/lib/hecks/projections/bootstrap_table.rb +12 -44
- data/lib/hecks/projections/deploy/fargate/assembly.rb +2 -15
- data/lib/hecks/projections/deploy/fargate/cdn.rb +1 -48
- data/lib/hecks/projections/deploy/fargate/cdn_default.rb +2 -11
- data/lib/hecks/projections/deploy/fargate/check.rb +5 -14
- data/lib/hecks/projections/deploy/fargate/containers.rb +3 -78
- data/lib/hecks/projections/deploy/fargate/extras.rb +3 -44
- data/lib/hecks/projections/deploy/fargate/monitoring.rb +0 -44
- data/lib/hecks/projections/deploy/fargate/settings.rb +2 -54
- data/lib/hecks/projections/deploy/fargate/yaml.rb +13 -58
- data/lib/hecks/projections/deploy/fargate.rb +40 -207
- data/lib/hecks/projections/deploy/lambda.rb +191 -708
- data/lib/hecks/projections/deploy/preview/access_yaml.rb +1 -33
- data/lib/hecks/projections/deploy/preview/containers.rb +6 -111
- data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
- data/lib/hecks/projections/deploy/preview/routing_yaml.rb +4 -56
- data/lib/hecks/projections/deploy/preview/script.rb +2 -14
- data/lib/hecks/projections/deploy/preview/settings.rb +3 -33
- data/lib/hecks/projections/deploy/preview/task_yaml.rb +2 -38
- data/lib/hecks/projections/deploy/preview/template.rb +3 -59
- data/lib/hecks/projections/deploy/preview.rb +2 -67
- data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/scripts/expected-era.header +1 -1
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +3 -3
- data/lib/hecks/projections/deploy/scripts/settings.rb +3 -25
- data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/scripts.rb +4 -57
- data/lib/hecks/projections/deploy/shared.rb +48 -147
- data/lib/hecks/projections/deploy/smoke.rb +4 -61
- data/lib/hecks/projections/deploy/template_diff/comparer.rb +3 -25
- data/lib/hecks/projections/deploy/template_diff/loader.rb +2 -11
- data/lib/hecks/projections/deploy/template_diff.rb +5 -57
- data/lib/hecks/projections/diagrams.rb +22 -584
- data/lib/hecks/projections/glossary/html.rb +14 -130
- data/lib/hecks/projections/glossary/markdown.rb +8 -69
- data/lib/hecks/projections/glossary/mermaid.rb +9 -66
- data/lib/hecks/projections/glossary/sensitivity.rb +5 -34
- data/lib/hecks/projections/glossary/sentences.rb +14 -159
- data/lib/hecks/projections/glossary.rb +30 -222
- data/lib/hecks/projections/ir.rb +3 -10
- data/lib/hecks/projections/model/deviations.rb +16 -43
- data/lib/hecks/projections/model.rb +12 -51
- data/lib/hecks/projections/oidc.rb +12 -56
- data/lib/hecks/projections/parser_table.rb +13 -32
- data/lib/hecks/projections/reference.rb +4 -18
- data/lib/hecks/projections/rust_vocabulary.rb +13 -251
- data/lib/hecks/projections/shape.rb +3 -29
- data/lib/hecks/projections/statements.rb +7 -86
- data/lib/hecks/projections/vocabulary.rb +6 -22
- data/lib/hecks/projections.rb +2 -16
- data/lib/hecks/projector/cli_projector.rb +154 -400
- data/lib/hecks/projector/docs_projector.rb +10 -201
- data/lib/hecks/projector/exporter.rb +42 -134
- data/lib/hecks/projector/ir_projector.rb +2 -9
- data/lib/hecks/projector/narrate_projector.rb +15 -189
- data/lib/hecks/projector/target.rb +13 -76
- data/lib/hecks/projector.rb +30 -121
- data/lib/hecks/quality_control/adapters/agent.adapter +7 -0
- data/lib/hecks/quality_control/adapters/agent.rb +58 -0
- data/lib/hecks/quality_control/adapters/angle_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/angle_tools.rb +15 -0
- data/lib/hecks/quality_control/adapters/clearance_recorder.rb +36 -0
- data/lib/hecks/quality_control/adapters/clearance_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/clearance_tools.rb +17 -0
- data/lib/hecks/quality_control/adapters/git_pr.adapter +7 -0
- data/lib/hecks/quality_control/adapters/git_pr.rb +226 -0
- data/lib/hecks/quality_control/adapters/github_checks.adapter +9 -0
- data/lib/hecks/quality_control/adapters/github_checks.rb +83 -0
- data/lib/hecks/quality_control/adapters/github_ci_webhook.rb +56 -0
- data/lib/hecks/quality_control/adapters/github_issues.adapter +7 -0
- data/lib/hecks/quality_control/adapters/github_issues.rb +49 -0
- data/lib/hecks/quality_control/adapters/qa_tool.rb +59 -0
- data/lib/hecks/quality_control/adapters/sweep_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/sweep_tools.rb +51 -0
- data/lib/hecks/quality_control/adapters/target_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/target_tools.rb +43 -0
- data/lib/hecks/quality_control/cli/child.rb +45 -0
- data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +45 -0
- data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +248 -0
- data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +163 -0
- data/lib/hecks/quality_control/cli/qa_generated_domains.rb +450 -0
- data/lib/hecks/quality_control/cli/qa_log_bug.rb +195 -0
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +220 -0
- data/lib/hecks/quality_control/cli/qa_open_pr.rb +305 -0
- data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +154 -0
- data/lib/hecks/quality_control/cli/qa_postgres_role.rb +159 -0
- data/lib/hecks/quality_control/cli/qa_pr_check.rb +232 -0
- data/lib/hecks/quality_control/cli/qa_seed_angles.rb +142 -0
- data/lib/hecks/quality_control/cli/qa_seed_targets.rb +69 -0
- data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +280 -0
- data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +329 -0
- data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +92 -0
- data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +117 -0
- data/lib/hecks/quality_control/cli/qa_sweep/parsing.rb +175 -0
- data/lib/hecks/quality_control/cli/qa_sweep/release_mode.rb +60 -0
- data/lib/hecks/quality_control/cli/qa_sweep.rb +611 -0
- data/lib/hecks/quality_control/cli/qa_tick.rb +274 -0
- data/lib/hecks/quality_control/quality_control.bluebook +2224 -0
- data/lib/hecks/quality_control/quality_control.hecksagon +13 -0
- data/lib/hecks/quality_control/quality_control.ports.hecksagon +54 -0
- data/lib/hecks/query_ir.rb +346 -0
- data/lib/hecks/query_ir_mcp.rb +198 -0
- data/lib/hecks/query_specification/common/comparators.rb +5 -26
- data/lib/hecks/query_specification/common/comparison.rb +35 -143
- data/lib/hecks/query_specification/common/dsl.rb +8 -24
- data/lib/hecks/query_specification/common/null_policy.rb +20 -70
- data/lib/hecks/query_specification/common/options.rb +2 -6
- data/lib/hecks/query_specification/common/where_clause.rb +2 -10
- data/lib/hecks/query_specification/field_path.rb +37 -77
- data/lib/hecks/query_specification/hop_path.rb +12 -76
- data/lib/hecks/release/gem_pin.rb +2 -26
- data/lib/hecks/release/runner/ci_publisher.rb +5 -8
- data/lib/hecks/release/runner/clean_tree.rb +42 -0
- data/lib/hecks/release/runner/commands.rb +2 -4
- data/lib/hecks/release/runner/console.rb +6 -0
- data/lib/hecks/release/runner/gem_publisher.rb +15 -9
- data/lib/hecks/release/runner/npm_publisher.rb +9 -53
- data/lib/hecks/release/runner/preflight.rb +29 -20
- data/lib/hecks/release/runner/published.rb +8 -26
- data/lib/hecks/release/runner/tagger.rb +31 -23
- data/lib/hecks/release/runner.rb +60 -38
- data/lib/hecks/rendering.rb +4 -26
- data/lib/hecks/router/namespace_installer.rb +15 -39
- data/lib/hecks/router.rb +4 -8
- data/lib/hecks/runtime/adapter_lookup.rb +124 -0
- data/lib/hecks/runtime/aggregate_lock.rb +8 -33
- data/lib/hecks/runtime/boot_gates.rb +3 -18
- data/lib/hecks/runtime/caller.rb +19 -59
- data/lib/hecks/runtime/capability_graph.rb +5 -21
- data/lib/hecks/runtime/command_interpreter/argument_gate.rb +16 -78
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +27 -160
- data/lib/hecks/runtime/command_interpreter.rb +124 -306
- data/lib/hecks/runtime/command_rules/admissibility.rb +95 -366
- data/lib/hecks/runtime/command_rules/arithmetic.rb +66 -196
- data/lib/hecks/runtime/command_rules/authorization.rb +13 -45
- data/lib/hecks/runtime/command_rules/emission.rb +8 -21
- data/lib/hecks/runtime/command_rules/references.rb +23 -202
- data/lib/hecks/runtime/dependency_planning.rb +50 -104
- data/lib/hecks/runtime/dispatcher.rb +134 -243
- data/lib/hecks/runtime/entity_element.rb +40 -462
- data/lib/hecks/runtime/entity_interpreter.rb +64 -219
- data/lib/hecks/runtime/errors.rb +32 -115
- data/lib/hecks/runtime/event.rb +3 -25
- data/lib/hecks/runtime/identity.rb +18 -154
- data/lib/hecks/runtime/instance.rb +18 -150
- data/lib/hecks/runtime/interpreting.rb +14 -70
- data/lib/hecks/runtime/invocation.rb +28 -164
- data/lib/hecks/runtime/loader.rb +109 -194
- data/lib/hecks/runtime/outbox.rb +39 -262
- data/lib/hecks/runtime/policy_interpreter.rb +43 -202
- data/lib/hecks/runtime/port_operation_interpreter.rb +28 -97
- data/lib/hecks/runtime/query_interpreter.rb +122 -187
- data/lib/hecks/runtime/reaction_invocation.rb +36 -138
- data/lib/hecks/runtime/reaction_outcome.rb +89 -0
- data/lib/hecks/runtime/read_model_interpreter.rb +117 -208
- data/lib/hecks/runtime/rebuild_sweep.rb +8 -54
- data/lib/hecks/runtime/reference_hop.rb +8 -81
- data/lib/hecks/runtime/refusal_wording.rb +28 -87
- data/lib/hecks/runtime/registry/saga_persistence.rb +10 -102
- data/lib/hecks/runtime/registry/verification.rb +213 -227
- data/lib/hecks/runtime/registry/world_defaults.rb +4 -53
- data/lib/hecks/runtime/registry.rb +156 -247
- data/lib/hecks/runtime/remote_dispatcher.rb +68 -154
- data/lib/hecks/runtime/routing.rb +4 -15
- data/lib/hecks/runtime/saga_interpreter/correlation.rb +14 -61
- data/lib/hecks/runtime/saga_interpreter.rb +79 -263
- data/lib/hecks/runtime/saga_pending_dispatch.rb +2 -43
- data/lib/hecks/runtime/tenant_check.rb +6 -68
- data/lib/hecks/runtime/tenant_scope.rb +3 -28
- data/lib/hecks/runtime/value/admission.rb +16 -67
- data/lib/hecks/runtime/value/coercion.rb +162 -760
- data/lib/hecks/runtime/value/entity_list_coercion.rb +26 -219
- data/lib/hecks/runtime/value/field_checks.rb +172 -0
- data/lib/hecks/runtime/value.rb +15 -84
- data/lib/hecks/runtime.rb +35 -69
- data/lib/hecks/rust_build/conformance.rb +158 -0
- data/lib/hecks/rust_build/conformance_fuzz.rb +71 -0
- data/lib/hecks/rust_build/coverage.rb +218 -0
- data/lib/hecks/rust_build/native_build.rb +124 -0
- data/lib/hecks/rust_build/project_rust/root_mod.rb +120 -0
- data/lib/hecks/rust_build/project_rust.rb +332 -0
- data/lib/hecks/rust_build/wasm.rb +105 -0
- data/lib/hecks/rust_build/wasm_browser.rb +113 -0
- data/lib/hecks/rust_build.rb +144 -0
- data/lib/hecks/storehouse.rb +155 -582
- data/lib/hecks/tenancy/bluebook/tenancy.bluebook +10 -34
- data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +6 -23
- data/lib/hecks/three_zero/forms.yml +127 -0
- data/lib/hecks/three_zero.rb +12 -0
- data/lib/hecks/tools/argument_gate_matrix.rb +316 -0
- data/lib/hecks/tools/comment_style.rb +1070 -0
- data/lib/hecks/tools/corpus_report.rb +87 -0
- data/lib/hecks/tools/deploy_recipe.rb +255 -0
- data/lib/hecks/tools/deploy_recipe_lint.rb +282 -0
- data/lib/hecks/tools/drop_implicit_append_fields.rb +95 -0
- data/lib/hecks/tools/era_projection_backfill.rb +94 -0
- data/lib/hecks/tools/era_reattest.rb +146 -0
- data/lib/hecks/tools/evolve_run.rb +189 -0
- data/lib/hecks/tools/fuzz_sweep.rb +373 -0
- data/lib/hecks/tools/heki_compaction.rb +100 -0
- data/lib/hecks/tools/hoist_local_givens.rb +246 -0
- data/lib/hecks/tools/journal_compaction.rb +89 -0
- data/lib/hecks/tools/oidc_manifests.rb +70 -0
- data/lib/hecks/tools/query_ir_run.rb +81 -0
- data/lib/hecks/tools/regeneration_run.rb +166 -0
- data/lib/hecks/tools/rust_comment_style.rb +642 -0
- data/lib/hecks/tools/sequence_script.rb +62 -0
- data/lib/hecks/tools/tail_merge.rb +117 -0
- data/lib/hecks/tools/tenant_provisioning.rb +142 -0
- data/lib/hecks/tools/translation_audit.rb +208 -0
- data/lib/hecks/tools/translation_scaffold.rb +131 -0
- data/lib/hecks/tools.rb +86 -0
- data/lib/hecks/vendoring/git_environment.rb +2 -8
- data/lib/hecks/vendoring/git_source.rb +4 -10
- data/lib/hecks/vendoring.rb +7 -46
- data/lib/hecks/version.rb +3 -14
- data/lib/hecks.rb +52 -90
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +8 -46
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +11 -80
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +10 -99
- data/qa/settings.yml +66 -0
- data/rust/.cargo/config.toml +6 -0
- data/rust/.gitignore +2 -0
- data/rust/Cargo.lock +54 -0
- data/rust/Cargo.toml +61 -0
- data/rust/build/.cargo/config.toml +3 -0
- data/rust/build/.gitignore +1 -0
- data/rust/build/Cargo.lock +7 -0
- data/rust/build/Cargo.toml +19 -0
- data/rust/build/src/build_artifact.rs +47 -0
- data/rust/build/src/cargo_sync.rs +248 -0
- data/rust/build/src/json.rs +421 -0
- data/rust/build/src/lineage_pass.rs +285 -0
- data/rust/build/src/main.rs +51 -0
- data/rust/build/src/optional_pass.rs +150 -0
- data/rust/build/src/pipeline.rs +264 -0
- data/rust/build/src/reserved_names.rs +76 -0
- data/rust/build/src/resolve.rs +124 -0
- data/rust/build/src/root.rs +25 -0
- data/rust/build/src/sidecars.rs +73 -0
- data/rust/build/src/subprocess.rs +51 -0
- data/rust/build/src/tmp.rs +31 -0
- data/rust/codegen/.cargo/config.toml +3 -0
- data/rust/codegen/.gitignore +1 -0
- data/rust/codegen/Cargo.lock +7 -0
- data/rust/codegen/Cargo.toml +20 -0
- data/rust/codegen/src/attr.rs +32 -0
- data/rust/codegen/src/bridging.rs +481 -0
- data/rust/codegen/src/commands.rs +1226 -0
- data/rust/codegen/src/constraints.rs +109 -0
- data/rust/codegen/src/dependency_planning.rs +209 -0
- data/rust/codegen/src/domain_generator.rs +1288 -0
- data/rust/codegen/src/exemplar.rs +230 -0
- data/rust/codegen/src/expr_emitter.rs +87 -0
- data/rust/codegen/src/fielded.rs +192 -0
- data/rust/codegen/src/hecks_naming.rs +33 -0
- data/rust/codegen/src/json.rs +335 -0
- data/rust/codegen/src/json_codec.rs +697 -0
- data/rust/codegen/src/literal.rs +207 -0
- data/rust/codegen/src/main.rs +290 -0
- data/rust/codegen/src/manifest.rs +227 -0
- data/rust/codegen/src/mutations.rs +784 -0
- data/rust/codegen/src/naming.rs +286 -0
- data/rust/codegen/src/ports.rs +208 -0
- data/rust/codegen/src/prelude.rs +116 -0
- data/rust/codegen/src/queries.rs +786 -0
- data/rust/codegen/src/reactions.rs +532 -0
- data/rust/codegen/src/read_models.rs +697 -0
- data/rust/codegen/src/reference_specs.rs +40 -0
- data/rust/codegen/src/registry.rs +966 -0
- data/rust/codegen/src/reserved_names.rs +76 -0
- data/rust/codegen/src/shared.rs +93 -0
- data/rust/codegen/src/skip_reason.rs +25 -0
- data/rust/codegen/src/types.rs +362 -0
- data/rust/host/.cargo/config.toml +3 -0
- data/rust/host/.gitignore +1 -0
- data/rust/host/Cargo.lock +4297 -0
- data/rust/host/Cargo.toml +61 -0
- data/rust/host/HECKS_RELEASE +1 -0
- data/rust/host/rds-ca-bundle.pem +2736 -0
- data/rust/host/src/api.rs +1596 -0
- data/rust/host/src/approval.rs +652 -0
- data/rust/host/src/auth.rs +1287 -0
- data/rust/host/src/bin/expr_harness.rs +46 -0
- data/rust/host/src/bin/lineage_harness.rs +119 -0
- data/rust/host/src/bin/mint_harness.rs +135 -0
- data/rust/host/src/checkout.rs +265 -0
- data/rust/host/src/dispatch.rs +1443 -0
- data/rust/host/src/expr_json.rs +1117 -0
- data/rust/host/src/field_hints.rs +52 -0
- data/rust/host/src/ir.rs +733 -0
- data/rust/host/src/journal.rs +874 -0
- data/rust/host/src/lambda_client.rs +378 -0
- data/rust/host/src/log.rs +147 -0
- data/rust/host/src/main.rs +474 -0
- data/rust/host/src/mint.rs +1865 -0
- data/rust/host/src/payments/keystore.rs +330 -0
- data/rust/host/src/payments/tests.rs +1400 -0
- data/rust/host/src/payments.rs +767 -0
- data/rust/host/src/presentation.rs +698 -0
- data/rust/host/src/presentation_write.rs +1431 -0
- data/rust/host/src/query_step.rs +149 -0
- data/rust/host/src/rate_limit.rs +904 -0
- data/rust/host/src/reference_transform.rs +241 -0
- data/rust/host/src/reference_validate.rs +338 -0
- data/rust/host/src/resend.rs +296 -0
- data/rust/host/src/secrets.rs +106 -0
- data/rust/host/src/server.rs +663 -0
- data/rust/host/src/storage_shape.rs +175 -0
- data/rust/host/src/test_pg.rs +40 -0
- data/rust/host/src/ui_schema.rs +1351 -0
- data/rust/host/src/wasm_runner.rs +191 -0
- data/rust/host/src/web/newsletter/cooldown.rs +126 -0
- data/rust/host/src/web/newsletter/tests.rs +104 -0
- data/rust/host/src/web/newsletter.rs +310 -0
- data/rust/host/src/web/newsletter_send.rs +357 -0
- data/rust/host/src/web/registrations.rs +1505 -0
- data/rust/host/src/web.rs +2937 -0
- data/rust/lsp/.gitignore +1 -0
- data/rust/lsp/Cargo.lock +7 -0
- data/rust/lsp/Cargo.toml +16 -0
- data/rust/lsp/README.md +133 -0
- data/rust/lsp/src/diagnostics.rs +165 -0
- data/rust/lsp/src/json.rs +385 -0
- data/rust/lsp/src/main.rs +399 -0
- data/rust/lsp/src/outline.rs +177 -0
- data/rust/lsp/src/rpc.rs +67 -0
- data/rust/parser/.cargo/config.toml +5 -0
- data/rust/parser/.gitignore +1 -0
- data/rust/parser/Cargo.lock +7 -0
- data/rust/parser/Cargo.toml +21 -0
- data/rust/parser/src/build/closed_sets.rs +27 -0
- data/rust/parser/src/build/identity.rs +276 -0
- data/rust/parser/src/build/mod.rs +12 -0
- data/rust/parser/src/build/naming.rs +141 -0
- data/rust/parser/src/build/pattern_subset.rs +161 -0
- data/rust/parser/src/build/query_derive.rs +72 -0
- data/rust/parser/src/build/query_inference.rs +269 -0
- data/rust/parser/src/build/query_options.rs +100 -0
- data/rust/parser/src/build/read_model.rs +94 -0
- data/rust/parser/src/build/references.rs +32 -0
- data/rust/parser/src/canonical.rs +114 -0
- data/rust/parser/src/diag.rs +55 -0
- data/rust/parser/src/emit.rs +953 -0
- data/rust/parser/src/expr/ast_json.rs +147 -0
- data/rust/parser/src/expr/evaluator.rs +81 -0
- data/rust/parser/src/expr/mod.rs +84 -0
- data/rust/parser/src/expr/resolver.rs +497 -0
- data/rust/parser/src/ir.rs +387 -0
- data/rust/parser/src/keywords.rs +585 -0
- data/rust/parser/src/lex.rs +662 -0
- data/rust/parser/src/main.rs +252 -0
- data/rust/parser/src/parse/aggregate.rs +612 -0
- data/rust/parser/src/parse/chapter.rs +517 -0
- data/rust/parser/src/parse/command.rs +637 -0
- data/rust/parser/src/parse/domain_port.rs +127 -0
- data/rust/parser/src/parse/entity.rs +527 -0
- data/rust/parser/src/parse/file.rs +67 -0
- data/rust/parser/src/parse/hecksagon.rs +231 -0
- data/rust/parser/src/parse/lifecycle.rs +162 -0
- data/rust/parser/src/parse/mod.rs +1262 -0
- data/rust/parser/src/parse/policy.rs +87 -0
- data/rust/parser/src/parse/process_manager.rs +305 -0
- data/rust/parser/src/parse/query.rs +100 -0
- data/rust/parser/src/parse/read_model.rs +214 -0
- data/rust/parser/src/parse/value_object.rs +250 -0
- data/rust/parser/src/ruby_value.rs +422 -0
- data/rust/project/bridging.rb +257 -0
- data/rust/project/commands.rb +877 -0
- data/rust/project/constraints.rb +86 -0
- data/rust/project/dependency_planning.rb +178 -0
- data/rust/project/domain_generator.rb +980 -0
- data/rust/project/exemplar.rb +148 -0
- data/rust/project/expr_emitter.rb +72 -0
- data/rust/project/fielded.rb +141 -0
- data/rust/project/json_codec.rb +641 -0
- data/rust/project/mutations.rb +721 -0
- data/rust/project/naming.rb +205 -0
- data/rust/project/ports.rb +65 -0
- data/rust/project/queries.rb +493 -0
- data/rust/project/reactions.rb +317 -0
- data/rust/project/read_models.rb +690 -0
- data/rust/project/reference_specs.rb +27 -0
- data/rust/project/registry.rb +396 -0
- data/rust/project/skip_reason.rb +29 -0
- data/rust/project/types.rb +225 -0
- data/rust/project/write_if_changed.rb +83 -0
- data/rust/project.rb +30 -0
- data/rust/project_rust_pipeline.rb +383 -0
- data/rust/src/exemplar/commands.rs +279 -0
- data/rust/src/exemplar/constraints.rs +18 -0
- data/rust/src/exemplar/fielded.rs +280 -0
- data/rust/src/exemplar/json.rs +277 -0
- data/rust/src/exemplar/mod.rs +17 -0
- data/rust/src/exemplar/mutations.rs +80 -0
- data/rust/src/exemplar/queries.rs +33 -0
- data/rust/src/exemplar/reactions.rs +88 -0
- data/rust/src/exemplar/read_models.rs +48 -0
- data/rust/src/exemplar/registry.rs +166 -0
- data/rust/src/exemplar/types.rs +44 -0
- data/rust/src/kernel/attribute_shapes/composite.rs +88 -0
- data/rust/src/kernel/attribute_shapes/list.rs +18 -0
- data/rust/src/kernel/attribute_shapes/mod.rs +44 -0
- data/rust/src/kernel/attribute_shapes/optional.rs +9 -0
- data/rust/src/kernel/attribute_shapes/scalar.rs +39 -0
- data/rust/src/kernel/cli.rs +585 -0
- data/rust/src/kernel/dispatch.rs +1090 -0
- data/rust/src/kernel/expr.rs +458 -0
- data/rust/src/kernel/expression_operators/arithmetic.rs +128 -0
- data/rust/src/kernel/expression_operators/comparison.rs +48 -0
- data/rust/src/kernel/expression_operators/enumeration.rs +232 -0
- data/rust/src/kernel/expression_operators/logical.rs +13 -0
- data/rust/src/kernel/expression_operators/membership.rs +18 -0
- data/rust/src/kernel/expression_operators/mod.rs +68 -0
- data/rust/src/kernel/expression_operators/pattern_match.rs +73 -0
- data/rust/src/kernel/expression_operators/positional.rs +126 -0
- data/rust/src/kernel/expression_operators/presence.rs +101 -0
- data/rust/src/kernel/expression_operators/sign_test.rs +16 -0
- data/rust/src/kernel/expression_operators/sized.rs +35 -0
- data/rust/src/kernel/expression_operators/text.rs +79 -0
- data/rust/src/kernel/expression_operators/to_string.rs +18 -0
- data/rust/src/kernel/json.rs +779 -0
- data/rust/src/kernel/mod.rs +133 -0
- data/rust/src/kernel/named_query.rs +219 -0
- data/rust/src/kernel/orchestrate.rs +1810 -0
- data/rust/src/kernel/pattern.rs +307 -0
- data/rust/src/kernel/query_comparators.rs +206 -0
- data/rust/src/kernel/query_ordering.rs +103 -0
- data/rust/src/kernel/read_model.rs +914 -0
- data/rust/src/kernel/reference_lookup.rs +149 -0
- data/rust/src/kernel/repository.rs +468 -0
- data/rust/src/kernel/routing.rs +612 -0
- data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +118 -0
- data/rust/src/kernel/vocab/entity_dispatch_order.rs +114 -0
- data/rust/src/kernel/vocab/field_hint.rs +70 -0
- data/rust/src/kernel/vocab/mod.rs +20 -0
- data/rust/src/kernel/vocab/query_comparator.rs +65 -0
- data/rust/src/kernel/vocab/refusal_template.rs +1592 -0
- data/rust/src/lib.rs +8 -0
- data/rust/src/main.rs +16 -0
- data/rust/web/.gitignore +1 -0
- data/rust/web/Cargo.lock +119 -0
- data/rust/web/Cargo.toml +18 -0
- data/rust/web/src/lib.rs +8 -0
- metadata +478 -12
- data/lib/hecks/facade/cli_door.rb +0 -178
- data/lib/hecks/facade/cli_runner.rb +0 -271
- data/lib/hecks/facade/handle.rb +0 -293
- data/lib/hecks/facade/json_door.rb +0 -247
- data/lib/hecks/facade/surface/aggregate_door.rb +0 -222
- data/lib/hecks/facade/surface/chapter.rb +0 -116
- data/lib/hecks/facade/surface.rb +0 -61
- data/lib/hecks/mcp_door_scope.rb +0 -176
|
@@ -0,0 +1,1810 @@
|
|
|
1
|
+
//! Reactions to dispatched events: policy matching and process-manager advancement, recursive
|
|
2
|
+
//! and bounded by `MAX_REACTION_DEPTH`. Cross-domain matches are returned for the host to deliver.
|
|
3
|
+
|
|
4
|
+
use super::expr::{interpret, EvalContext, Expr, NoFields};
|
|
5
|
+
use super::named_query;
|
|
6
|
+
use super::repository::AggregateScan;
|
|
7
|
+
use super::{Event, Json, MutationRecord, Refusal};
|
|
8
|
+
use std::collections::HashMap;
|
|
9
|
+
|
|
10
|
+
pub const MAX_REACTION_DEPTH: usize = 5;
|
|
11
|
+
|
|
12
|
+
/// One `policy` block whose target lives in this domain.
|
|
13
|
+
pub struct PolicyRule {
|
|
14
|
+
pub policy_name: &'static str,
|
|
15
|
+
pub event_name: &'static str,
|
|
16
|
+
pub event_qualifier: Option<&'static str>,
|
|
17
|
+
pub target_verb: &'static str,
|
|
18
|
+
/// `where { ... }` predicate. A function because an `Expr` owns boxes and cannot sit in a
|
|
19
|
+
/// const table.
|
|
20
|
+
pub where_expr: Option<fn() -> Expr>,
|
|
21
|
+
/// Domain-qualified query verb a fan-out runs, settled at codegen.
|
|
22
|
+
pub for_each: Option<&'static str>,
|
|
23
|
+
/// Name each matched row's ID is minted under, resolved at codegen from the target command.
|
|
24
|
+
/// The target command, not the aggregate name, decides it (`account` vs `account_id`).
|
|
25
|
+
pub for_each_key: Option<&'static str>,
|
|
26
|
+
/// `trigger ..., with:` as `(argument_name, binding)` pairs; empty forwards the whole payload.
|
|
27
|
+
/// A binding starting with ":" names a source field; any other is a rendered literal.
|
|
28
|
+
pub with_spec: &'static [(&'static str, &'static str)],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// A policy block with `across "OtherDomain"`: matched here, delivered by the host.
|
|
32
|
+
pub struct CrossDomainPolicyRule {
|
|
33
|
+
pub policy_name: &'static str,
|
|
34
|
+
pub event_name: &'static str,
|
|
35
|
+
pub event_qualifier: Option<&'static str>,
|
|
36
|
+
pub where_expr: Option<fn() -> Expr>,
|
|
37
|
+
pub target_domain: &'static str,
|
|
38
|
+
pub target_verb: &'static str,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// A matched cross-domain reaction the host must deliver; the payload is forwarded unchanged.
|
|
42
|
+
#[derive(Clone)]
|
|
43
|
+
pub struct PendingCrossDomainReaction {
|
|
44
|
+
pub policy_name: String,
|
|
45
|
+
pub event_name: String,
|
|
46
|
+
pub target_domain: String,
|
|
47
|
+
pub target_verb: String,
|
|
48
|
+
pub payload: Json,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// A `with:` binding value. `Literal` is a function because `Json` cannot be built in a const
|
|
52
|
+
/// table.
|
|
53
|
+
pub enum WithValue {
|
|
54
|
+
Ref(&'static str),
|
|
55
|
+
Literal(fn() -> Json),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
pub struct DispatchSpec {
|
|
59
|
+
pub command_name: &'static str,
|
|
60
|
+
pub with: &'static [(&'static str, WithValue)],
|
|
61
|
+
/// The command that undoes this dispatch, if any. A reference so the const table can nest it;
|
|
62
|
+
/// a compensation is never itself compensable.
|
|
63
|
+
pub compensates: Option<&'static DispatchSpec>,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// One leg of a process manager: on `event_type` from `from_state`, move to `to_state` and
|
|
67
|
+
/// dispatch.
|
|
68
|
+
pub struct Handler {
|
|
69
|
+
pub event_type: &'static str,
|
|
70
|
+
pub from_state: &'static str,
|
|
71
|
+
pub to_state: &'static str,
|
|
72
|
+
pub dispatches: &'static [DispatchSpec],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// One `process_manager` block; `initial_state` is the first declared state.
|
|
76
|
+
pub struct ProcessManagerDef {
|
|
77
|
+
pub name: &'static str,
|
|
78
|
+
pub correlates_by: &'static str,
|
|
79
|
+
pub starts_on: &'static str,
|
|
80
|
+
pub ends_on: &'static str,
|
|
81
|
+
pub initial_state: &'static str,
|
|
82
|
+
pub handlers: &'static [Handler],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Event type a compensating leg (`on :refused`) is declared under.
|
|
86
|
+
pub const REFUSED: &str = "refused";
|
|
87
|
+
|
|
88
|
+
/// A live process-manager instance, keyed `(process_manager_name, correlation)` by its owner.
|
|
89
|
+
#[derive(Clone)]
|
|
90
|
+
pub struct SagaInstance {
|
|
91
|
+
pub state: String,
|
|
92
|
+
pub memory: Json,
|
|
93
|
+
/// Compensations of completed dispatches, oldest first so `pop` drains newest-first.
|
|
94
|
+
/// Recorded before the forward dispatch runs (see `deliver_saga_dispatch`).
|
|
95
|
+
pub completed_compensations: Vec<CompletedCompensation>,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// A compensation ledger entry; `args` are resolved when the entry is pushed.
|
|
99
|
+
/// Owned strings, because it round-trips through the saga snapshot JSON.
|
|
100
|
+
#[derive(Clone)]
|
|
101
|
+
pub struct CompletedCompensation {
|
|
102
|
+
pub command_name: String,
|
|
103
|
+
pub args: Json,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Build refuses two legs on one (event, state) pair, so the first match is the only one.
|
|
107
|
+
fn select_leg<'a>(pm: &'a ProcessManagerDef, event: &str, state: &str) -> Option<&'a Handler> {
|
|
108
|
+
pm.handlers.iter().find(|h| h.event_type == event && h.from_state == state)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fn leg_mismatch(pm: &ProcessManagerDef, event: &str, state: &str) -> String {
|
|
112
|
+
let mut expected: Vec<String> = Vec::new();
|
|
113
|
+
for h in pm.handlers.iter().filter(|h| h.event_type == event) {
|
|
114
|
+
let spelled = format!("{:?}", h.from_state);
|
|
115
|
+
if !expected.contains(&spelled) {
|
|
116
|
+
expected.push(spelled);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
format!("in {state:?}, not {}", expected.join(" or "))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fn correlation_head(correlates_by: &str) -> &str {
|
|
123
|
+
correlates_by.split('.').next().unwrap_or(correlates_by)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// An empty correlation counts as absent, so callers need only one check.
|
|
127
|
+
fn correlation_of(pm: &ProcessManagerDef, event: &Event, reference_key_fn: fn(&str) -> Option<&'static str>) -> Option<String> {
|
|
128
|
+
// Tier 1: a dotted path into the event's payload.
|
|
129
|
+
if let Some(v) = event.payload.dig(pm.correlates_by) {
|
|
130
|
+
if let Ok(id) = v.to_id_component() {
|
|
131
|
+
if !id.is_empty() {
|
|
132
|
+
return Some(id);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Tier 2: the correlation an earlier saga-leg dispatch stamped on the event.
|
|
138
|
+
if let Some(stamp) = &event.correlation {
|
|
139
|
+
if let Some(v) = stamp.get(correlation_head(pm.correlates_by)) {
|
|
140
|
+
if !v.is_empty() {
|
|
141
|
+
return Some(v.clone());
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Tier 3: the aggregate's reference key, for a leg dispatched through `reference_to`.
|
|
147
|
+
let key = reference_key_fn(&event.aggregate)?;
|
|
148
|
+
let id = event.payload.get(key)?.to_id_component().ok()?;
|
|
149
|
+
if id.is_empty() {
|
|
150
|
+
None
|
|
151
|
+
} else {
|
|
152
|
+
Some(id)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fn resolve_with(pm: &ProcessManagerDef, value: &WithValue, event: &Event, correlation: &str, memory: &Json) -> Json {
|
|
157
|
+
match value {
|
|
158
|
+
WithValue::Literal(build) => build(),
|
|
159
|
+
WithValue::Ref(name) => {
|
|
160
|
+
let head = correlation_head(pm.correlates_by);
|
|
161
|
+
if *name == head {
|
|
162
|
+
Json::Str(correlation.to_string())
|
|
163
|
+
} else if let Some(v) = event.payload.get(name) {
|
|
164
|
+
v.clone()
|
|
165
|
+
} else if let Some(v) = memory.get(name) {
|
|
166
|
+
v.clone()
|
|
167
|
+
} else {
|
|
168
|
+
Json::Null
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Always domain-qualifies, and folds the entity-nesting `::` to `.`: generated dispatch arms are
|
|
175
|
+
// dot-joined past the aggregate ("Waybill::Manifest.Slot.Fill"). Guessing from a leftover `::`
|
|
176
|
+
// cannot tell a cross-domain command from a nested entity's.
|
|
177
|
+
fn qualify_saga_command_name(domain_name: &str, command_name: &str) -> String {
|
|
178
|
+
format!("{domain_name}::{}", command_name.replacen("::", ".", 1))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
fn build_dispatch_args(pm: &ProcessManagerDef, spec: &DispatchSpec, event: &Event, correlation: &str, memory: &Json, domain_name: &str, tables: &Tables) -> Json {
|
|
182
|
+
let projected = Json::Object(spec.with.iter().map(|(key, value)| (key.to_string(), resolve_with(pm, value, event, correlation, memory))).collect());
|
|
183
|
+
// A saga leg's `with:` is always an explicit projection. The verb is qualified before the
|
|
184
|
+
// lookup because the tables are keyed by the qualified verb.
|
|
185
|
+
let qualified = qualify_saga_command_name(domain_name, spec.command_name);
|
|
186
|
+
route_dispatch_args(projected, &qualified, tables, event)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#[derive(Clone, Copy)]
|
|
190
|
+
pub struct Tables<'a> {
|
|
191
|
+
pub policies: &'a [PolicyRule],
|
|
192
|
+
pub cross_domain_policies: &'a [CrossDomainPolicyRule],
|
|
193
|
+
pub process_managers: &'a [ProcessManagerDef],
|
|
194
|
+
pub reference_key_fn: fn(&str) -> Option<&'static str>,
|
|
195
|
+
pub queries: &'a [crate::kernel::QueryDef],
|
|
196
|
+
pub command_creates_fn: fn(&str) -> bool,
|
|
197
|
+
pub identity_head_fn: fn(&str) -> Option<&'static str>,
|
|
198
|
+
// Slices a reaction's `with:` facts to the target command's declared attributes.
|
|
199
|
+
pub command_attributes_fn: fn(&str) -> &'static [&'static str],
|
|
200
|
+
// Keyed by "Domain::Aggregate.Entity".
|
|
201
|
+
pub entity_identity_head_fn: fn(&str) -> Option<&'static str>,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Dispatches `verb`, then recursively runs the policies and saga legs its events trigger.
|
|
205
|
+
/// Only a refusal of the top-level `verb` is returned; reaction refusals are logged and swallowed.
|
|
206
|
+
///
|
|
207
|
+
/// `saga_correlation` stamps every event this call produces; only saga-leg dispatches pass it.
|
|
208
|
+
#[allow(clippy::too_many_arguments)]
|
|
209
|
+
pub fn orchestrate<S: AggregateScan>(
|
|
210
|
+
store: &mut S,
|
|
211
|
+
dispatch_fn: fn(&mut S, &str, &Json, Option<&str>, Option<&str>, &mut Vec<MutationRecord>) -> Result<Vec<Event>, Refusal>,
|
|
212
|
+
tables: Tables<'static>,
|
|
213
|
+
sagas: &mut HashMap<(String, String), SagaInstance>,
|
|
214
|
+
verb: &str,
|
|
215
|
+
args: &Json,
|
|
216
|
+
caller_role: Option<&str>,
|
|
217
|
+
// Outermost dispatch only, like `caller_role`: reactions are system-triggered and pass `None`.
|
|
218
|
+
caller_actor_id: Option<&str>,
|
|
219
|
+
saga_correlation: Option<&HashMap<String, String>>,
|
|
220
|
+
// Threaded unchanged into every recursion: one host-supplied moment for the whole cascade.
|
|
221
|
+
occurred_at: Option<&str>,
|
|
222
|
+
depth: usize,
|
|
223
|
+
all_events: &mut Vec<Event>,
|
|
224
|
+
mutations: &mut Vec<MutationRecord>,
|
|
225
|
+
cross_domain: &mut Vec<PendingCrossDomainReaction>,
|
|
226
|
+
reaction_log: &mut Vec<Json>,
|
|
227
|
+
saga_log: &mut Vec<Json>,
|
|
228
|
+
) -> Result<(), Refusal> {
|
|
229
|
+
let mut events = dispatch_fn(store, verb, args, caller_role, caller_actor_id, mutations)?;
|
|
230
|
+
|
|
231
|
+
if let Some(stamp) = saga_correlation {
|
|
232
|
+
for event in &mut events {
|
|
233
|
+
event.correlation.get_or_insert_with(HashMap::new).extend(stamp.iter().map(|(k, v)| (k.clone(), v.clone())));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if let Some(ts) = occurred_at {
|
|
238
|
+
for event in &mut events {
|
|
239
|
+
event.occurred_at = Some(ts.to_string());
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Log the whole batch before any reaction runs, so a later event never logs after an
|
|
244
|
+
// earlier event's reactions.
|
|
245
|
+
for event in &events {
|
|
246
|
+
all_events.push(event.clone());
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Then per event in `emits` order: its policies, then its sagas.
|
|
250
|
+
for event in events {
|
|
251
|
+
// Saga begin/advance/end run for every event regardless of depth; only the re-entry is
|
|
252
|
+
// depth-gated, inside `react_policies` and `deliver_saga_dispatch`.
|
|
253
|
+
react_policies(store, dispatch_fn, tables, sagas, &event, occurred_at, depth, all_events, mutations, cross_domain, reaction_log, saga_log);
|
|
254
|
+
begin_saga(tables, sagas, &event, saga_log);
|
|
255
|
+
advance_saga(store, dispatch_fn, tables, sagas, &event, occurred_at, depth, all_events, mutations, cross_domain, reaction_log, saga_log);
|
|
256
|
+
end_saga(tables, sagas, &event, saga_log);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
Ok(())
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Decodes a rendered `with:` literal. Wire text keeps its quote marks, so passing it through
|
|
263
|
+
// verbatim would hand a quoted string to `from_json` and the trigger would never fire.
|
|
264
|
+
fn read_literal_wire(binding: &str) -> Json {
|
|
265
|
+
if binding == "nil" {
|
|
266
|
+
return Json::Null;
|
|
267
|
+
}
|
|
268
|
+
if binding == "true" {
|
|
269
|
+
return Json::Bool(true);
|
|
270
|
+
}
|
|
271
|
+
if binding == "false" {
|
|
272
|
+
return Json::Bool(false);
|
|
273
|
+
}
|
|
274
|
+
if let Ok(i) = binding.parse::<i64>() {
|
|
275
|
+
return Json::int(i);
|
|
276
|
+
}
|
|
277
|
+
if let Ok(f) = binding.parse::<f64>() {
|
|
278
|
+
return Json::Float(f);
|
|
279
|
+
}
|
|
280
|
+
if binding.len() >= 2 && binding.starts_with('"') && binding.ends_with('"') {
|
|
281
|
+
let inner = &binding[1..binding.len() - 1];
|
|
282
|
+
let mut unescaped = String::with_capacity(inner.len());
|
|
283
|
+
let mut chars = inner.chars();
|
|
284
|
+
while let Some(c) = chars.next() {
|
|
285
|
+
if c == '\\' {
|
|
286
|
+
if let Some(next) = chars.next() {
|
|
287
|
+
unescaped.push(next);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
unescaped.push(c);
|
|
292
|
+
}
|
|
293
|
+
return Json::str(unescaped);
|
|
294
|
+
}
|
|
295
|
+
// A bare word `Literal.read` also tolerates (its own comment: "a
|
|
296
|
+
// closed set's members and a few hand-written fields... never
|
|
297
|
+
// rendered") — passed through as-is, matching that same tolerance.
|
|
298
|
+
Json::str(binding.to_string())
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
fn where_holds(where_expr: Option<fn() -> Expr>, event: &Event) -> bool {
|
|
302
|
+
let Some(build) = where_expr else { return true };
|
|
303
|
+
let ctx = EvalContext { args: &event.payload, instance: &NoFields };
|
|
304
|
+
matches!(interpret(&build(), &ctx), Ok(v) if v.truthy())
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// An empty `with_spec` forwards the whole payload. `extra` (a fan-out's row key) is merged into
|
|
308
|
+
// the source before projection, not onto its result.
|
|
309
|
+
fn trigger_args(policy: &PolicyRule, event: &Event, extra: Option<(&str, String)>, target_verb: &str, tables: &Tables) -> Json {
|
|
310
|
+
let mut source: Vec<(String, Json)> = match &event.payload {
|
|
311
|
+
Json::Object(pairs) => pairs.clone(),
|
|
312
|
+
_ => Vec::new(),
|
|
313
|
+
};
|
|
314
|
+
if let Some((key, id)) = &extra {
|
|
315
|
+
source.retain(|(name, _)| name != key);
|
|
316
|
+
source.push(((*key).to_string(), Json::str(id.clone())));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if policy.with_spec.is_empty() {
|
|
320
|
+
// No `with:`: lift the same-aggregate event id into the receiver of a non-creating
|
|
321
|
+
// target, as `ReactionInvocation.build` does. Never for a `for_each` row, whose id is
|
|
322
|
+
// already in `source` under the fan-out's own name.
|
|
323
|
+
if extra.is_none() && !(tables.command_creates_fn)(target_verb) {
|
|
324
|
+
if let Some(aggregate_name) = target_verb.rsplit_once('.').map(|(agg, _)| agg) {
|
|
325
|
+
if aggregate_name == event.aggregate {
|
|
326
|
+
// The lifted receiver replaces any `to` the payload carried; that fact never
|
|
327
|
+
// reaches the target.
|
|
328
|
+
source.retain(|(name, _)| name != "to");
|
|
329
|
+
return Json::obj(vec![("to", Json::str(event.id.clone())), ("with", Json::Object(source))]);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return Json::Object(source);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// An explicit projection may read the emitting record's identity, offered under the
|
|
337
|
+
// aggregate's identity head unless the payload already carries it.
|
|
338
|
+
if let Some(head) = (tables.identity_head_fn)(&event.aggregate) {
|
|
339
|
+
if !event.id.is_empty() && !source.iter().any(|(name, _)| name == head) {
|
|
340
|
+
source.push((head.to_string(), Json::str(event.id.clone())));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let projected = policy
|
|
345
|
+
.with_spec
|
|
346
|
+
.iter()
|
|
347
|
+
.map(|(name, binding)| {
|
|
348
|
+
let value = match binding.strip_prefix(':') {
|
|
349
|
+
Some(field) => source
|
|
350
|
+
.iter()
|
|
351
|
+
.find(|(held, _)| held == field)
|
|
352
|
+
.map(|(_, held)| held.clone())
|
|
353
|
+
.unwrap_or(Json::Null),
|
|
354
|
+
None => read_literal_wire(binding),
|
|
355
|
+
};
|
|
356
|
+
((*name).to_string(), value)
|
|
357
|
+
})
|
|
358
|
+
.collect();
|
|
359
|
+
// Only an explicit `with:` is split; an undeclared projection forwards the payload unsplit.
|
|
360
|
+
let routed = split_routed_args(Json::Object(projected), target_verb, tables);
|
|
361
|
+
// Fallback when nothing named the target's identity: the source's own is the receiver for a
|
|
362
|
+
// non-creating target on the same aggregate. Projected facts, even a `to`, stay facts.
|
|
363
|
+
if extra.is_none() && !(tables.command_creates_fn)(target_verb) {
|
|
364
|
+
if let Json::Object(pairs) = &routed {
|
|
365
|
+
let already_routed = pairs.iter().any(|(k, _)| k == "to") && pairs.iter().any(|(k, _)| k == "with");
|
|
366
|
+
if !already_routed {
|
|
367
|
+
if let Some(aggregate_name) = target_verb.rsplit_once('.').map(|(agg, _)| agg) {
|
|
368
|
+
if aggregate_name == event.aggregate {
|
|
369
|
+
return Json::obj(vec![("to", Json::str(event.id.clone())), ("with", routed)]);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
routed
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Splits resolved args into `{to:, with:}` for a non-creating target with a single-component
|
|
379
|
+
// identity. Composite identities and targets the tables know nothing about stay flat: unknown
|
|
380
|
+
// means don't route. `facts` slices the original pairs to the command's declared attributes.
|
|
381
|
+
fn split_routed_args(projected: Json, target_verb: &str, tables: &Tables) -> Json {
|
|
382
|
+
let Json::Object(pairs) = projected else { return projected };
|
|
383
|
+
let declared = (tables.command_attributes_fn)(target_verb);
|
|
384
|
+
let facts: Vec<(String, Json)> = pairs.iter().filter(|(k, _)| declared.contains(&k.as_str())).cloned().collect();
|
|
385
|
+
|
|
386
|
+
if (tables.command_creates_fn)(target_verb) {
|
|
387
|
+
return Json::Object(facts);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
let Some(aggregate_name) = target_verb.rsplit_once('.').map(|(agg, _)| agg) else {
|
|
391
|
+
return Json::Object(facts);
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
let candidates = [(tables.identity_head_fn)(aggregate_name), (tables.reference_key_fn)(aggregate_name)];
|
|
395
|
+
for key in candidates.into_iter().flatten() {
|
|
396
|
+
if let Some((_, raw_id)) = pairs.iter().find(|(k, _)| k == key) {
|
|
397
|
+
// A value-object identity arrives as `{"value": ...}`; `resolved_id_component`
|
|
398
|
+
// unwraps it.
|
|
399
|
+
let Some(id) = resolved_id_component(raw_id) else { continue };
|
|
400
|
+
return Json::obj(vec![("to", Json::str(id)), ("with", Json::Object(facts))]);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
Json::Object(facts)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Splits a one-level entity command ("Domain::Aggregate.Entity.Command") into aggregate and
|
|
407
|
+
// entity paths; `None` for any other depth. `split_once` because the domain boundary is `::`.
|
|
408
|
+
fn entity_command_paths(target_verb: &str) -> Option<(&str, String)> {
|
|
409
|
+
let (aggregate_name, rest) = target_verb.split_once('.')?;
|
|
410
|
+
let (entity_name, command_rest) = rest.split_once('.')?;
|
|
411
|
+
if command_rest.contains('.') {
|
|
412
|
+
return None;
|
|
413
|
+
}
|
|
414
|
+
Some((aggregate_name, format!("{aggregate_name}.{entity_name}")))
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// A value-object field is stored as `{"value": ...}`: try the value as-is, then its `value` once.
|
|
418
|
+
fn resolved_id_component(v: &Json) -> Option<String> {
|
|
419
|
+
v.to_id_component().ok().or_else(|| v.get("value").and_then(|inner| inner.to_id_component().ok())).filter(|id| !id.is_empty())
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Routes a one-level entity command that `split_routed_args` cannot: the parent aggregate's
|
|
423
|
+
// identity comes from the triggering event, not from the `with:` facts. Every other shape falls
|
|
424
|
+
// through to `split_routed_args`'s answer.
|
|
425
|
+
fn route_dispatch_args(projected: Json, target_verb: &str, tables: &Tables, event: &Event) -> Json {
|
|
426
|
+
let routed = split_routed_args(projected.clone(), target_verb, tables);
|
|
427
|
+
if matches!(&routed, Json::Object(fields) if fields.iter().any(|(k, _)| k == "to")) {
|
|
428
|
+
return routed;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
let Some((aggregate_name, entity_qualified)) = entity_command_paths(target_verb) else { return routed };
|
|
432
|
+
let Json::Object(pairs) = &projected else { return routed };
|
|
433
|
+
|
|
434
|
+
// Entity identity: its identity attribute, present in the `with:` facts by that name.
|
|
435
|
+
let Some(entity_head) = (tables.entity_identity_head_fn)(&entity_qualified) else { return routed };
|
|
436
|
+
let Some(entity_id) = pairs.iter().find(|(k, _)| k == entity_head).and_then(|(_, v)| resolved_id_component(v)) else {
|
|
437
|
+
return routed;
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// Aggregate identity: a projected identity/reference key, else the triggering event's own id
|
|
441
|
+
// when it is the same aggregate.
|
|
442
|
+
let candidates = [(tables.identity_head_fn)(aggregate_name), (tables.reference_key_fn)(aggregate_name)];
|
|
443
|
+
let aggregate_id = candidates
|
|
444
|
+
.into_iter()
|
|
445
|
+
.flatten()
|
|
446
|
+
.find_map(|key| pairs.iter().find(|(k, _)| k == key).and_then(|(_, v)| resolved_id_component(v)))
|
|
447
|
+
.or_else(|| (event.aggregate == aggregate_name && !event.id.is_empty()).then(|| event.id.clone()));
|
|
448
|
+
let Some(aggregate_id) = aggregate_id else { return routed };
|
|
449
|
+
|
|
450
|
+
let declared = (tables.command_attributes_fn)(target_verb);
|
|
451
|
+
let facts: Vec<(String, Json)> = pairs.iter().filter(|(k, _)| declared.contains(&k.as_str())).cloned().collect();
|
|
452
|
+
Json::obj(vec![
|
|
453
|
+
("to", Json::obj(vec![("aggregate", Json::str(aggregate_id)), ("entities", Json::Array(vec![Json::str(entity_id)]))])),
|
|
454
|
+
("with", Json::Object(facts)),
|
|
455
|
+
])
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
#[allow(clippy::too_many_arguments)]
|
|
459
|
+
fn react_policies<S: AggregateScan>(
|
|
460
|
+
store: &mut S,
|
|
461
|
+
dispatch_fn: fn(&mut S, &str, &Json, Option<&str>, Option<&str>, &mut Vec<MutationRecord>) -> Result<Vec<Event>, Refusal>,
|
|
462
|
+
tables: Tables<'static>,
|
|
463
|
+
sagas: &mut HashMap<(String, String), SagaInstance>,
|
|
464
|
+
event: &Event,
|
|
465
|
+
occurred_at: Option<&str>,
|
|
466
|
+
depth: usize,
|
|
467
|
+
all_events: &mut Vec<Event>,
|
|
468
|
+
mutations: &mut Vec<MutationRecord>,
|
|
469
|
+
cross_domain: &mut Vec<PendingCrossDomainReaction>,
|
|
470
|
+
reaction_log: &mut Vec<Json>,
|
|
471
|
+
saga_log: &mut Vec<Json>,
|
|
472
|
+
) {
|
|
473
|
+
let emitting = event.aggregate.rsplit("::").next().unwrap_or(event.aggregate.as_str());
|
|
474
|
+
|
|
475
|
+
for policy in tables.policies {
|
|
476
|
+
if policy.event_name != event.name {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if let Some(qualifier) = policy.event_qualifier {
|
|
480
|
+
if qualifier != emitting {
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if !where_holds(policy.where_expr, event) {
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
let record = |extra: Vec<(&str, Json)>| -> Json {
|
|
489
|
+
let mut fields = vec![
|
|
490
|
+
("policy", Json::str(policy.policy_name.to_string())),
|
|
491
|
+
("on", Json::str(event.name.clone())),
|
|
492
|
+
("trigger", Json::str(policy.target_verb.to_string())),
|
|
493
|
+
];
|
|
494
|
+
fields.extend(extra.into_iter().map(|(k, v)| (k, v)));
|
|
495
|
+
Json::obj(fields)
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
if depth + 1 >= MAX_REACTION_DEPTH {
|
|
499
|
+
reaction_log.push(record(vec![
|
|
500
|
+
("delivered", Json::Bool(false)),
|
|
501
|
+
("reason", Json::str(format!("reaction depth {MAX_REACTION_DEPTH} reached"))),
|
|
502
|
+
]));
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// A fan-out dispatches once per row its query answers; the row id is merged into the
|
|
507
|
+
// source a `with:` projection reads from.
|
|
508
|
+
if let Some(for_each) = policy.for_each {
|
|
509
|
+
let Some(def) = named_query::find(tables.queries, for_each) else {
|
|
510
|
+
reaction_log.push(record(vec![
|
|
511
|
+
("delivered", Json::Bool(false)),
|
|
512
|
+
("reason", Json::str(format!("no query {for_each}"))),
|
|
513
|
+
]));
|
|
514
|
+
continue;
|
|
515
|
+
};
|
|
516
|
+
// The query reads the event, not the projection. No caller role: the policy is
|
|
517
|
+
// system-triggered.
|
|
518
|
+
let rows = match named_query::run(store, def, &event.payload, None) {
|
|
519
|
+
Ok(rows) => rows,
|
|
520
|
+
Err(refusal) => {
|
|
521
|
+
reaction_log.push(record(vec![
|
|
522
|
+
("delivered", Json::Bool(false)),
|
|
523
|
+
("reason", Json::str(refusal.to_string())),
|
|
524
|
+
]));
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
for (row_id, _row) in rows {
|
|
529
|
+
let row_record = |extra: Vec<(&str, Json)>| -> Json {
|
|
530
|
+
let mut fields = vec![("for_row", Json::str(row_id.clone()))];
|
|
531
|
+
fields.extend(extra.into_iter());
|
|
532
|
+
let base = record(vec![]);
|
|
533
|
+
match base {
|
|
534
|
+
Json::Object(mut pairs) => {
|
|
535
|
+
pairs.extend(fields.into_iter().map(|(k, v)| (k.to_string(), v)));
|
|
536
|
+
Json::Object(pairs)
|
|
537
|
+
}
|
|
538
|
+
other => other,
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
let args = trigger_args(policy, event, policy.for_each_key.map(|key| (key, row_id.clone())), policy.target_verb, &tables);
|
|
542
|
+
let outcome = orchestrate(
|
|
543
|
+
store, dispatch_fn, tables, sagas, policy.target_verb, &args, None, None, None, occurred_at, depth + 1,
|
|
544
|
+
all_events, mutations, cross_domain, reaction_log, saga_log,
|
|
545
|
+
);
|
|
546
|
+
match outcome {
|
|
547
|
+
Ok(()) => reaction_log.push(row_record(vec![("delivered", Json::Bool(true))])),
|
|
548
|
+
Err(refusal) => reaction_log.push(row_record(vec![
|
|
549
|
+
("delivered", Json::Bool(false)),
|
|
550
|
+
("reason", Json::str(refusal.to_string())),
|
|
551
|
+
])),
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// No `with:`: the whole payload forwards verbatim. Reactions are system-triggered, so no
|
|
558
|
+
// caller role, and only saga legs stamp a correlation.
|
|
559
|
+
let args = trigger_args(policy, event, None, policy.target_verb, &tables);
|
|
560
|
+
let outcome = orchestrate(
|
|
561
|
+
store, dispatch_fn, tables, sagas, policy.target_verb, &args, None, None, None, occurred_at, depth + 1,
|
|
562
|
+
all_events, mutations, cross_domain, reaction_log, saga_log,
|
|
563
|
+
);
|
|
564
|
+
match outcome {
|
|
565
|
+
Ok(()) => reaction_log.push(record(vec![("delivered", Json::Bool(true))])),
|
|
566
|
+
// A refusal is recorded, not fatal to the emitting command. A panic is not caught, on
|
|
567
|
+
// purpose.
|
|
568
|
+
Err(refusal) => reaction_log.push(record(vec![
|
|
569
|
+
("delivered", Json::Bool(false)),
|
|
570
|
+
("reason", Json::str(refusal.to_string())),
|
|
571
|
+
])),
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Cross-domain matches are recorded for the host to deliver, never dispatched or logged here:
|
|
576
|
+
// the delivery outcome does not exist yet.
|
|
577
|
+
for policy in tables.cross_domain_policies {
|
|
578
|
+
if policy.event_name != event.name {
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if let Some(qualifier) = policy.event_qualifier {
|
|
582
|
+
if qualifier != emitting {
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if !where_holds(policy.where_expr, event) {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
cross_domain.push(PendingCrossDomainReaction {
|
|
590
|
+
policy_name: policy.policy_name.to_string(),
|
|
591
|
+
event_name: event.name.clone(),
|
|
592
|
+
target_domain: policy.target_domain.to_string(),
|
|
593
|
+
target_verb: policy.target_verb.to_string(),
|
|
594
|
+
payload: event.payload.clone(),
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Silent when `event.name != pm.starts_on` or the instance already exists.
|
|
600
|
+
fn begin_saga(tables: Tables<'static>, sagas: &mut HashMap<(String, String), SagaInstance>, event: &Event, saga_log: &mut Vec<Json>) {
|
|
601
|
+
for pm in tables.process_managers {
|
|
602
|
+
if event.name != pm.starts_on {
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
let Some(correlation) = correlation_of(pm, event, tables.reference_key_fn) else {
|
|
607
|
+
saga_log.push(Json::obj(vec![
|
|
608
|
+
("process_manager", Json::str(pm.name.to_string())),
|
|
609
|
+
("on", Json::str(event.name.clone())),
|
|
610
|
+
("born", Json::Bool(false)),
|
|
611
|
+
("reason", Json::str(format!("no {} in the payload", pm.correlates_by))),
|
|
612
|
+
]));
|
|
613
|
+
continue;
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
let key = (pm.name.to_string(), correlation.clone());
|
|
617
|
+
if sagas.contains_key(&key) {
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
sagas.insert(key, SagaInstance { state: pm.initial_state.to_string(), memory: event.payload.clone(), completed_compensations: Vec::new() });
|
|
622
|
+
saga_log.push(Json::obj(vec![
|
|
623
|
+
("process_manager", Json::str(pm.name.to_string())),
|
|
624
|
+
("on", Json::str(event.name.clone())),
|
|
625
|
+
("instance", Json::str(correlation)),
|
|
626
|
+
("born", Json::Bool(true)),
|
|
627
|
+
("state", Json::str(pm.initial_state.to_string())),
|
|
628
|
+
]));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Silent when no handler answers the event or correlation resolves to nothing.
|
|
633
|
+
#[allow(clippy::too_many_arguments)]
|
|
634
|
+
fn advance_saga<S: AggregateScan>(
|
|
635
|
+
store: &mut S,
|
|
636
|
+
dispatch_fn: fn(&mut S, &str, &Json, Option<&str>, Option<&str>, &mut Vec<MutationRecord>) -> Result<Vec<Event>, Refusal>,
|
|
637
|
+
tables: Tables<'static>,
|
|
638
|
+
sagas: &mut HashMap<(String, String), SagaInstance>,
|
|
639
|
+
event: &Event,
|
|
640
|
+
occurred_at: Option<&str>,
|
|
641
|
+
depth: usize,
|
|
642
|
+
all_events: &mut Vec<Event>,
|
|
643
|
+
mutations: &mut Vec<MutationRecord>,
|
|
644
|
+
cross_domain: &mut Vec<PendingCrossDomainReaction>,
|
|
645
|
+
reaction_log: &mut Vec<Json>,
|
|
646
|
+
saga_log: &mut Vec<Json>,
|
|
647
|
+
) {
|
|
648
|
+
for pm in tables.process_managers {
|
|
649
|
+
if !pm.handlers.iter().any(|h| h.event_type == event.name) {
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
let Some(correlation) = correlation_of(pm, event, tables.reference_key_fn) else { continue };
|
|
653
|
+
|
|
654
|
+
let key = (pm.name.to_string(), correlation.clone());
|
|
655
|
+
let record = |extra: Vec<(&str, Json)>| -> Json {
|
|
656
|
+
let mut fields = vec![
|
|
657
|
+
("process_manager", Json::str(pm.name.to_string())),
|
|
658
|
+
("on", Json::str(event.name.clone())),
|
|
659
|
+
("instance", Json::str(correlation.clone())),
|
|
660
|
+
];
|
|
661
|
+
fields.extend(extra.into_iter().map(|(k, v)| (k, v)));
|
|
662
|
+
Json::obj(fields)
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
let Some(state) = sagas.get(&key).map(|instance| instance.state.clone()) else {
|
|
666
|
+
saga_log.push(record(vec![
|
|
667
|
+
("advanced", Json::Bool(false)),
|
|
668
|
+
("reason", Json::str(format!("no conversation remembers {correlation:?}"))),
|
|
669
|
+
]));
|
|
670
|
+
continue;
|
|
671
|
+
};
|
|
672
|
+
// The leg is chosen by (event, current state); build refuses two on one pair.
|
|
673
|
+
let Some(handler) = select_leg(pm, &event.name, &state) else {
|
|
674
|
+
saga_log.push(record(vec![
|
|
675
|
+
("advanced", Json::Bool(false)),
|
|
676
|
+
("reason", Json::str(leg_mismatch(pm, &event.name, &state))),
|
|
677
|
+
]));
|
|
678
|
+
continue;
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
if let Some(slot) = sagas.get_mut(&key) {
|
|
682
|
+
slot.state = handler.to_state.to_string();
|
|
683
|
+
}
|
|
684
|
+
saga_log.push(record(vec![
|
|
685
|
+
("advanced", Json::Bool(true)),
|
|
686
|
+
("from", Json::str(handler.from_state.to_string())),
|
|
687
|
+
("to", Json::str(handler.to_state.to_string())),
|
|
688
|
+
]));
|
|
689
|
+
|
|
690
|
+
let memory = sagas.get(&key).map(|instance| instance.memory.clone()).unwrap_or(Json::Null);
|
|
691
|
+
// `event.aggregate` is `{domain}::{aggregate}`, so the domain is read from it.
|
|
692
|
+
let domain_name = event.aggregate.split("::").next().unwrap_or(&event.aggregate);
|
|
693
|
+
let mut refused = false;
|
|
694
|
+
for spec in handler.dispatches {
|
|
695
|
+
let args = build_dispatch_args(pm, spec, event, &correlation, &memory, domain_name, &tables);
|
|
696
|
+
// Resolved before the forward dispatch: the ledger entry is pushed speculatively.
|
|
697
|
+
let compensation_args = spec.compensates.map(|r| build_dispatch_args(pm, r, event, &correlation, &memory, domain_name, &tables));
|
|
698
|
+
let stamp: HashMap<String, String> = [(correlation_head(pm.correlates_by).to_string(), correlation.clone())].into_iter().collect();
|
|
699
|
+
let delivered = deliver_saga_dispatch(
|
|
700
|
+
store, dispatch_fn, tables, sagas, pm, spec, domain_name, &args, &correlation, occurred_at, depth, all_events, mutations, cross_domain, reaction_log, saga_log, &stamp, compensation_args,
|
|
701
|
+
);
|
|
702
|
+
if delivered == Some(false) {
|
|
703
|
+
refused = true;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if refused {
|
|
708
|
+
compensate(store, dispatch_fn, tables, sagas, pm, &key, event, &correlation, &memory, occurred_at, depth, all_events, mutations, cross_domain, reaction_log, saga_log);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Returns `Some(delivered)`, or `None` when the depth ceiling stopped the leg before it tried.
|
|
714
|
+
// The compensation is pushed before the forward dispatch runs: a nested refusal re-entering this
|
|
715
|
+
// saga must already see it. It is popped again if this leg's own attempt refuses.
|
|
716
|
+
#[allow(clippy::too_many_arguments)]
|
|
717
|
+
fn deliver_saga_dispatch<S: AggregateScan>(
|
|
718
|
+
store: &mut S,
|
|
719
|
+
dispatch_fn: fn(&mut S, &str, &Json, Option<&str>, Option<&str>, &mut Vec<MutationRecord>) -> Result<Vec<Event>, Refusal>,
|
|
720
|
+
tables: Tables<'static>,
|
|
721
|
+
sagas: &mut HashMap<(String, String), SagaInstance>,
|
|
722
|
+
pm: &ProcessManagerDef,
|
|
723
|
+
spec: &DispatchSpec,
|
|
724
|
+
domain_name: &str,
|
|
725
|
+
args: &Json,
|
|
726
|
+
correlation: &str,
|
|
727
|
+
occurred_at: Option<&str>,
|
|
728
|
+
depth: usize,
|
|
729
|
+
all_events: &mut Vec<Event>,
|
|
730
|
+
mutations: &mut Vec<MutationRecord>,
|
|
731
|
+
cross_domain: &mut Vec<PendingCrossDomainReaction>,
|
|
732
|
+
reaction_log: &mut Vec<Json>,
|
|
733
|
+
saga_log: &mut Vec<Json>,
|
|
734
|
+
stamp: &HashMap<String, String>,
|
|
735
|
+
compensation_args: Option<Json>,
|
|
736
|
+
) -> Option<bool> {
|
|
737
|
+
// Ruby logs the unqualified name and qualifies only at dispatch; this mirrors that split.
|
|
738
|
+
let record = |extra: Vec<(&str, Json)>| -> Json {
|
|
739
|
+
let mut fields = vec![
|
|
740
|
+
("process_manager", Json::str(pm.name.to_string())),
|
|
741
|
+
("instance", Json::str(correlation.to_string())),
|
|
742
|
+
("dispatch", Json::str(spec.command_name.to_string())),
|
|
743
|
+
];
|
|
744
|
+
fields.extend(extra.into_iter().map(|(k, v)| (k, v)));
|
|
745
|
+
Json::obj(fields)
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
if depth + 1 >= MAX_REACTION_DEPTH {
|
|
749
|
+
saga_log.push(record(vec![
|
|
750
|
+
("delivered", Json::Bool(false)),
|
|
751
|
+
("reason", Json::str(format!("reaction depth {MAX_REACTION_DEPTH} reached"))),
|
|
752
|
+
]));
|
|
753
|
+
return None;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// `spec.command_name` is bare on the wire, so it is qualified before `dispatch_by_name`.
|
|
757
|
+
let qualified = qualify_saga_command_name(domain_name, spec.command_name);
|
|
758
|
+
|
|
759
|
+
let key = (pm.name.to_string(), correlation.to_string());
|
|
760
|
+
let mut compensation_recorded = false;
|
|
761
|
+
if let (Some(compensates_spec), Some(r_args)) = (spec.compensates, compensation_args) {
|
|
762
|
+
if let Some(slot) = sagas.get_mut(&key) {
|
|
763
|
+
slot.completed_compensations.push(CompletedCompensation { command_name: compensates_spec.command_name.to_string(), args: r_args });
|
|
764
|
+
compensation_recorded = true;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// System-triggered, so no caller role; `Some(stamp)` stamps every event this dispatch
|
|
769
|
+
// produces.
|
|
770
|
+
let outcome = orchestrate(
|
|
771
|
+
store, dispatch_fn, tables, sagas, &qualified, args, None, None, Some(stamp), occurred_at, depth + 1,
|
|
772
|
+
all_events, mutations, cross_domain, reaction_log, saga_log,
|
|
773
|
+
);
|
|
774
|
+
match outcome {
|
|
775
|
+
Ok(()) => {
|
|
776
|
+
saga_log.push(record(vec![("delivered", Json::Bool(true))]));
|
|
777
|
+
Some(true)
|
|
778
|
+
}
|
|
779
|
+
Err(refusal) => {
|
|
780
|
+
// This leg failed, so its speculative entry was never earned. `pop` is safe: any
|
|
781
|
+
// entry pushed after it by a nested call was already drained by `compensate`.
|
|
782
|
+
if compensation_recorded {
|
|
783
|
+
if let Some(slot) = sagas.get_mut(&key) {
|
|
784
|
+
slot.completed_compensations.pop();
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
saga_log.push(record(vec![
|
|
788
|
+
("delivered", Json::Bool(false)),
|
|
789
|
+
("reason", Json::str(refusal.to_string())),
|
|
790
|
+
]));
|
|
791
|
+
Some(false)
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Fires one ledger entry, newest-first. Args are pre-resolved, so `build_dispatch_args` is
|
|
797
|
+
// skipped. A compensation that itself refuses is logged with `compensation_failed` and never
|
|
798
|
+
// re-triggers `compensate`.
|
|
799
|
+
#[allow(clippy::too_many_arguments)]
|
|
800
|
+
fn deliver_derived_compensation<S: AggregateScan>(
|
|
801
|
+
store: &mut S,
|
|
802
|
+
dispatch_fn: fn(&mut S, &str, &Json, Option<&str>, Option<&str>, &mut Vec<MutationRecord>) -> Result<Vec<Event>, Refusal>,
|
|
803
|
+
tables: Tables<'static>,
|
|
804
|
+
sagas: &mut HashMap<(String, String), SagaInstance>,
|
|
805
|
+
pm: &ProcessManagerDef,
|
|
806
|
+
entry: &CompletedCompensation,
|
|
807
|
+
domain_name: &str,
|
|
808
|
+
correlation: &str,
|
|
809
|
+
occurred_at: Option<&str>,
|
|
810
|
+
depth: usize,
|
|
811
|
+
all_events: &mut Vec<Event>,
|
|
812
|
+
mutations: &mut Vec<MutationRecord>,
|
|
813
|
+
cross_domain: &mut Vec<PendingCrossDomainReaction>,
|
|
814
|
+
reaction_log: &mut Vec<Json>,
|
|
815
|
+
saga_log: &mut Vec<Json>,
|
|
816
|
+
) {
|
|
817
|
+
let record = |extra: Vec<(&str, Json)>| -> Json {
|
|
818
|
+
let mut fields = vec![
|
|
819
|
+
("process_manager", Json::str(pm.name.to_string())),
|
|
820
|
+
("instance", Json::str(correlation.to_string())),
|
|
821
|
+
("dispatch", Json::str(entry.command_name.to_string())),
|
|
822
|
+
];
|
|
823
|
+
fields.extend(extra.into_iter().map(|(k, v)| (k, v)));
|
|
824
|
+
Json::obj(fields)
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
if depth + 1 >= MAX_REACTION_DEPTH {
|
|
828
|
+
saga_log.push(record(vec![
|
|
829
|
+
("delivered", Json::Bool(false)),
|
|
830
|
+
("reason", Json::str(format!("reaction depth {MAX_REACTION_DEPTH} reached"))),
|
|
831
|
+
("compensation", Json::Bool(true)),
|
|
832
|
+
("compensation_failed", Json::Bool(true)),
|
|
833
|
+
]));
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// A compensation's `command_name` is bare on the wire, like a forward dispatch's.
|
|
838
|
+
let qualified = qualify_saga_command_name(domain_name, &entry.command_name);
|
|
839
|
+
|
|
840
|
+
let stamp: HashMap<String, String> = [(correlation_head(pm.correlates_by).to_string(), correlation.to_string())].into_iter().collect();
|
|
841
|
+
|
|
842
|
+
let outcome = orchestrate(
|
|
843
|
+
store, dispatch_fn, tables, sagas, &qualified, &entry.args, None, None, Some(&stamp), occurred_at, depth + 1,
|
|
844
|
+
all_events, mutations, cross_domain, reaction_log, saga_log,
|
|
845
|
+
);
|
|
846
|
+
match outcome {
|
|
847
|
+
Ok(()) => saga_log.push(record(vec![("delivered", Json::Bool(true)), ("compensation", Json::Bool(true))])),
|
|
848
|
+
Err(refusal) => saga_log.push(record(vec![
|
|
849
|
+
("delivered", Json::Bool(false)),
|
|
850
|
+
("reason", Json::str(refusal.to_string())),
|
|
851
|
+
("compensation", Json::Bool(true)),
|
|
852
|
+
("compensation_failed", Json::Bool(true)),
|
|
853
|
+
])),
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Silent when `event.name != pm.ends_on`, correlation is absent, or no instance existed.
|
|
858
|
+
fn end_saga(tables: Tables<'static>, sagas: &mut HashMap<(String, String), SagaInstance>, event: &Event, saga_log: &mut Vec<Json>) {
|
|
859
|
+
for pm in tables.process_managers {
|
|
860
|
+
if event.name != pm.ends_on {
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
let Some(correlation) = correlation_of(pm, event, tables.reference_key_fn) else { continue };
|
|
864
|
+
let key = (pm.name.to_string(), correlation.clone());
|
|
865
|
+
if sagas.remove(&key).is_none() {
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
saga_log.push(Json::obj(vec![
|
|
870
|
+
("process_manager", Json::str(pm.name.to_string())),
|
|
871
|
+
("on", Json::str(event.name.clone())),
|
|
872
|
+
("instance", Json::str(correlation)),
|
|
873
|
+
("ended", Json::Bool(true)),
|
|
874
|
+
]));
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// The `on :refused` leg, run once against the post-transition state. Its own dispatches never
|
|
879
|
+
// compensate on failure.
|
|
880
|
+
#[allow(clippy::too_many_arguments)]
|
|
881
|
+
fn compensate<S: AggregateScan>(
|
|
882
|
+
store: &mut S,
|
|
883
|
+
dispatch_fn: fn(&mut S, &str, &Json, Option<&str>, Option<&str>, &mut Vec<MutationRecord>) -> Result<Vec<Event>, Refusal>,
|
|
884
|
+
tables: Tables<'static>,
|
|
885
|
+
sagas: &mut HashMap<(String, String), SagaInstance>,
|
|
886
|
+
pm: &ProcessManagerDef,
|
|
887
|
+
key: &(String, String),
|
|
888
|
+
event: &Event,
|
|
889
|
+
correlation: &str,
|
|
890
|
+
memory: &Json,
|
|
891
|
+
occurred_at: Option<&str>,
|
|
892
|
+
depth: usize,
|
|
893
|
+
all_events: &mut Vec<Event>,
|
|
894
|
+
mutations: &mut Vec<MutationRecord>,
|
|
895
|
+
cross_domain: &mut Vec<PendingCrossDomainReaction>,
|
|
896
|
+
reaction_log: &mut Vec<Json>,
|
|
897
|
+
saga_log: &mut Vec<Json>,
|
|
898
|
+
) {
|
|
899
|
+
let Some(current_state) = sagas.get(key).map(|instance| instance.state.clone()) else { return };
|
|
900
|
+
// A process manager with no compensating leg is silent; a state no leg answers from is logged
|
|
901
|
+
// as `advanced: false`.
|
|
902
|
+
if !pm.handlers.iter().any(|h| h.event_type == REFUSED) {
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
let record = |extra: Vec<(&str, Json)>| -> Json {
|
|
907
|
+
let mut fields = vec![
|
|
908
|
+
("process_manager", Json::str(pm.name.to_string())),
|
|
909
|
+
("on", Json::str(REFUSED.to_string())),
|
|
910
|
+
("instance", Json::str(correlation.to_string())),
|
|
911
|
+
];
|
|
912
|
+
fields.extend(extra.into_iter().map(|(k, v)| (k, v)));
|
|
913
|
+
Json::obj(fields)
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
let Some(compensation) = select_leg(pm, REFUSED, ¤t_state) else {
|
|
917
|
+
saga_log.push(record(vec![
|
|
918
|
+
("advanced", Json::Bool(false)),
|
|
919
|
+
("reason", Json::str(leg_mismatch(pm, REFUSED, ¤t_state))),
|
|
920
|
+
]));
|
|
921
|
+
return;
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
if let Some(slot) = sagas.get_mut(key) {
|
|
925
|
+
slot.state = compensation.to_state.to_string();
|
|
926
|
+
}
|
|
927
|
+
saga_log.push(record(vec![
|
|
928
|
+
("advanced", Json::Bool(true)),
|
|
929
|
+
("from", Json::str(compensation.from_state.to_string())),
|
|
930
|
+
("to", Json::str(compensation.to_state.to_string())),
|
|
931
|
+
]));
|
|
932
|
+
|
|
933
|
+
let domain_name = event.aggregate.split("::").next().unwrap_or(&event.aggregate);
|
|
934
|
+
|
|
935
|
+
// Derived compensations first, newest-first. The ledger is re-fetched each iteration, not
|
|
936
|
+
// snapshotted: a nested reaction may push onto it mid-drain. If one removes the instance
|
|
937
|
+
// mid-drain, the loop stops early.
|
|
938
|
+
loop {
|
|
939
|
+
let entry = match sagas.get_mut(key) {
|
|
940
|
+
Some(instance) => instance.completed_compensations.pop(),
|
|
941
|
+
None => None,
|
|
942
|
+
};
|
|
943
|
+
let Some(entry) = entry else { break };
|
|
944
|
+
deliver_derived_compensation(store, dispatch_fn, tables, sagas, pm, &entry, domain_name, correlation, occurred_at, depth, all_events, mutations, cross_domain, reaction_log, saga_log);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
for spec in compensation.dispatches {
|
|
948
|
+
let args = build_dispatch_args(pm, spec, event, correlation, memory, domain_name, &tables);
|
|
949
|
+
let compensation_args = spec.compensates.map(|r| build_dispatch_args(pm, r, event, correlation, memory, domain_name, &tables));
|
|
950
|
+
let stamp: HashMap<String, String> = [(correlation_head(pm.correlates_by).to_string(), correlation.to_string())].into_iter().collect();
|
|
951
|
+
deliver_saga_dispatch(
|
|
952
|
+
store, dispatch_fn, tables, sagas, pm, spec, domain_name, &args, correlation, occurred_at, depth, all_events, mutations, cross_domain, reaction_log, saga_log, &stamp, compensation_args,
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
#[cfg(test)]
|
|
958
|
+
mod tests {
|
|
959
|
+
use super::*;
|
|
960
|
+
|
|
961
|
+
// Pins the decode of a rendered `with:` literal: raw wire text reaching `from_json` left the
|
|
962
|
+
// trigger silently unfired.
|
|
963
|
+
#[test]
|
|
964
|
+
fn decodes_a_quoted_string_literal_back_to_its_bare_value() {
|
|
965
|
+
assert_eq!(read_literal_wire("\"officer\""), Json::str("officer"));
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
#[test]
|
|
969
|
+
fn unescapes_an_embedded_quote_or_backslash_the_same_way_literal_quote_escaped_it() {
|
|
970
|
+
assert_eq!(read_literal_wire("\"a\\\"b\\\\c\""), Json::str("a\"b\\c"));
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
#[test]
|
|
974
|
+
fn decodes_the_bare_scalar_forms_literal_render_never_quotes() {
|
|
975
|
+
assert_eq!(read_literal_wire("nil"), Json::Null);
|
|
976
|
+
assert_eq!(read_literal_wire("true"), Json::Bool(true));
|
|
977
|
+
assert_eq!(read_literal_wire("false"), Json::Bool(false));
|
|
978
|
+
assert_eq!(read_literal_wire("42"), Json::int(42));
|
|
979
|
+
assert_eq!(read_literal_wire("3.5"), Json::Float(3.5));
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
#[test]
|
|
983
|
+
fn passes_a_bare_unrendered_word_through_unchanged() {
|
|
984
|
+
assert_eq!(read_literal_wire("officer"), Json::str("officer"));
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
fn pm(correlates_by: &'static str) -> ProcessManagerDef {
|
|
988
|
+
ProcessManagerDef {
|
|
989
|
+
name: "TestSaga",
|
|
990
|
+
correlates_by,
|
|
991
|
+
starts_on: "Started",
|
|
992
|
+
ends_on: "Ended",
|
|
993
|
+
initial_state: "start",
|
|
994
|
+
handlers: &[],
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
fn no_reference_key(_aggregate: &str) -> Option<&'static str> {
|
|
999
|
+
None
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Tier 2 alone: tier 1 finds nothing and tier 3 is unconfigured, so only the stamp can
|
|
1003
|
+
// resolve it.
|
|
1004
|
+
#[test]
|
|
1005
|
+
fn correlation_of_reads_the_stamp_when_the_payload_has_nothing_and_no_reference_key_applies() {
|
|
1006
|
+
let pm = pm("reference.value");
|
|
1007
|
+
let event = Event {
|
|
1008
|
+
name: "SomeEvent".to_string(),
|
|
1009
|
+
aggregate: "Domain::Thing".to_string(),
|
|
1010
|
+
id: "thing-1".to_string(),
|
|
1011
|
+
payload: Json::Object(vec![]),
|
|
1012
|
+
occurred_at: None,
|
|
1013
|
+
correlation: Some([("reference".to_string(), "xfer-1".to_string())].into_iter().collect()),
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
assert_eq!(correlation_of(&pm, &event, no_reference_key), Some("xfer-1".to_string()));
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
#[test]
|
|
1020
|
+
fn correlation_of_prefers_tier_1_over_the_stamp_when_both_are_present() {
|
|
1021
|
+
let pm = pm("reference.value");
|
|
1022
|
+
let event = Event {
|
|
1023
|
+
name: "SomeEvent".to_string(),
|
|
1024
|
+
aggregate: "Domain::Thing".to_string(),
|
|
1025
|
+
id: "thing-1".to_string(),
|
|
1026
|
+
payload: Json::Object(vec![("reference".to_string(), Json::Object(vec![("value".to_string(), Json::Str("from-payload".to_string()))]))]),
|
|
1027
|
+
occurred_at: None,
|
|
1028
|
+
correlation: Some([("reference".to_string(), "from-stamp".to_string())].into_iter().collect()),
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
assert_eq!(correlation_of(&pm, &event, no_reference_key), Some("from-payload".to_string()));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
#[test]
|
|
1035
|
+
fn correlation_of_ignores_an_empty_stamped_value_and_falls_through() {
|
|
1036
|
+
let pm = pm("reference.value");
|
|
1037
|
+
let event = Event {
|
|
1038
|
+
name: "SomeEvent".to_string(),
|
|
1039
|
+
aggregate: "Domain::Thing".to_string(),
|
|
1040
|
+
id: "thing-1".to_string(),
|
|
1041
|
+
payload: Json::Object(vec![]),
|
|
1042
|
+
occurred_at: None,
|
|
1043
|
+
correlation: Some([("reference".to_string(), String::new())].into_iter().collect()),
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1046
|
+
assert_eq!(correlation_of(&pm, &event, no_reference_key), None);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// The stamping step of `orchestrate`, exercised without a real `dispatch_fn`.
|
|
1050
|
+
#[test]
|
|
1051
|
+
fn stamping_merges_into_an_existing_correlation_map_rather_than_replacing_it() {
|
|
1052
|
+
let mut events = vec![
|
|
1053
|
+
Event {
|
|
1054
|
+
name: "E".to_string(),
|
|
1055
|
+
aggregate: "Domain::Thing".to_string(),
|
|
1056
|
+
id: "thing-1".to_string(),
|
|
1057
|
+
payload: Json::Null,
|
|
1058
|
+
occurred_at: None,
|
|
1059
|
+
correlation: Some([("already".to_string(), "here".to_string())].into_iter().collect()),
|
|
1060
|
+
},
|
|
1061
|
+
];
|
|
1062
|
+
let stamp: HashMap<String, String> = [("reference".to_string(), "xfer-1".to_string())].into_iter().collect();
|
|
1063
|
+
for event in &mut events {
|
|
1064
|
+
event.correlation.get_or_insert_with(HashMap::new).extend(stamp.iter().map(|(k, v)| (k.clone(), v.clone())));
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
let correlation = events[0].correlation.as_ref().unwrap();
|
|
1068
|
+
assert_eq!(correlation.get("already"), Some(&"here".to_string()));
|
|
1069
|
+
assert_eq!(correlation.get("reference"), Some(&"xfer-1".to_string()));
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// The `occurred_at` stamping step of `orchestrate`, exercised the same way.
|
|
1073
|
+
#[test]
|
|
1074
|
+
fn occurred_at_stamps_every_event_the_same_host_supplied_moment() {
|
|
1075
|
+
let mut events = vec![
|
|
1076
|
+
Event { name: "A".to_string(), aggregate: "Domain::Thing".to_string(), id: "t1".to_string(), payload: Json::Null, occurred_at: None, correlation: None },
|
|
1077
|
+
Event { name: "B".to_string(), aggregate: "Domain::Thing".to_string(), id: "t2".to_string(), payload: Json::Null, occurred_at: None, correlation: None },
|
|
1078
|
+
];
|
|
1079
|
+
let occurred_at = Some("2026-08-28T00:00:00Z");
|
|
1080
|
+
|
|
1081
|
+
if let Some(ts) = occurred_at {
|
|
1082
|
+
for event in &mut events {
|
|
1083
|
+
event.occurred_at = Some(ts.to_string());
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
assert_eq!(events[0].occurred_at, Some("2026-08-28T00:00:00Z".to_string()));
|
|
1088
|
+
assert_eq!(events[1].occurred_at, Some("2026-08-28T00:00:00Z".to_string()));
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
#[test]
|
|
1092
|
+
fn occurred_at_leaves_events_unstamped_when_no_caller_supplied_one() {
|
|
1093
|
+
let mut events = vec![Event { name: "A".to_string(), aggregate: "Domain::Thing".to_string(), id: "t1".to_string(), payload: Json::Null, occurred_at: None, correlation: None }];
|
|
1094
|
+
let occurred_at: Option<&str> = None;
|
|
1095
|
+
|
|
1096
|
+
if let Some(ts) = occurred_at {
|
|
1097
|
+
for event in &mut events {
|
|
1098
|
+
event.occurred_at = Some(ts.to_string());
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
assert_eq!(events[0].occurred_at, None);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Pins the speculative ledger record: legs A, B, C where C refuses two calls deep, before A's
|
|
1106
|
+
// and B's `deliver_saga_dispatch` return. Recording after success would leave the ledger empty
|
|
1107
|
+
// when `compensate` drains it, so neither `RA` nor `RB` would fire.
|
|
1108
|
+
struct MultiLegTestStore;
|
|
1109
|
+
impl AggregateScan for MultiLegTestStore {}
|
|
1110
|
+
|
|
1111
|
+
fn multi_leg_test_dispatch(
|
|
1112
|
+
_store: &mut MultiLegTestStore,
|
|
1113
|
+
verb: &str,
|
|
1114
|
+
_args: &Json,
|
|
1115
|
+
_caller_role: Option<&str>,
|
|
1116
|
+
_caller_actor_id: Option<&str>,
|
|
1117
|
+
_mutations: &mut Vec<MutationRecord>,
|
|
1118
|
+
) -> Result<Vec<Event>, Refusal> {
|
|
1119
|
+
let plain_event = |name: &str| Event {
|
|
1120
|
+
name: name.to_string(),
|
|
1121
|
+
aggregate: "Test::Widget".to_string(),
|
|
1122
|
+
id: "w1".to_string(),
|
|
1123
|
+
payload: Json::Object(vec![]),
|
|
1124
|
+
occurred_at: None,
|
|
1125
|
+
correlation: None,
|
|
1126
|
+
};
|
|
1127
|
+
match verb {
|
|
1128
|
+
"Test::Kickoff" => Ok(vec![Event {
|
|
1129
|
+
name: "Started".to_string(),
|
|
1130
|
+
aggregate: "Test::Widget".to_string(),
|
|
1131
|
+
id: "w1".to_string(),
|
|
1132
|
+
payload: Json::obj(vec![("id", Json::str("corr-1"))]),
|
|
1133
|
+
occurred_at: None,
|
|
1134
|
+
correlation: None,
|
|
1135
|
+
}]),
|
|
1136
|
+
"Test::A" => Ok(vec![plain_event("AEvent")]),
|
|
1137
|
+
"Test::B" => Ok(vec![plain_event("BEvent")]),
|
|
1138
|
+
"Test::C" => Err(Refusal::GivenNotMet("C always refuses".to_string())),
|
|
1139
|
+
"Test::RA" | "Test::RB" => Ok(vec![]),
|
|
1140
|
+
other => panic!("unexpected verb in multi-leg reentrancy test: {other}"),
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
fn multi_leg_no_reference_key(_aggregate: &str) -> Option<&'static str> {
|
|
1145
|
+
None
|
|
1146
|
+
}
|
|
1147
|
+
fn multi_leg_always_creates(_verb: &str) -> bool {
|
|
1148
|
+
true
|
|
1149
|
+
}
|
|
1150
|
+
fn multi_leg_no_identity_head(_aggregate: &str) -> Option<&'static str> {
|
|
1151
|
+
None
|
|
1152
|
+
}
|
|
1153
|
+
fn multi_leg_no_declared_attributes(_verb: &str) -> &'static [&'static str] {
|
|
1154
|
+
&[]
|
|
1155
|
+
}
|
|
1156
|
+
fn multi_leg_no_entity_identity_head(_qualified_path: &str) -> Option<&'static str> {
|
|
1157
|
+
None
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
#[test]
|
|
1161
|
+
fn multi_leg_reentrant_saga_fires_completed_compensations_newest_first_on_a_later_legs_refusal() {
|
|
1162
|
+
// `DISPATCH_RA`/`RB` are named statics; the forward legs are inline literals because a
|
|
1163
|
+
// static's value cannot be moved into an array (`DispatchSpec` is not `Copy`).
|
|
1164
|
+
static DISPATCH_RA: DispatchSpec = DispatchSpec { command_name: "RA", with: &[], compensates: None };
|
|
1165
|
+
static DISPATCH_RB: DispatchSpec = DispatchSpec { command_name: "RB", with: &[], compensates: None };
|
|
1166
|
+
|
|
1167
|
+
static HANDLERS: &[Handler] = &[
|
|
1168
|
+
Handler {
|
|
1169
|
+
event_type: "Started",
|
|
1170
|
+
from_state: "start",
|
|
1171
|
+
to_state: "state1",
|
|
1172
|
+
dispatches: &[DispatchSpec { command_name: "A", with: &[], compensates: Some(&DISPATCH_RA) }],
|
|
1173
|
+
},
|
|
1174
|
+
Handler {
|
|
1175
|
+
event_type: "AEvent",
|
|
1176
|
+
from_state: "state1",
|
|
1177
|
+
to_state: "state2",
|
|
1178
|
+
dispatches: &[DispatchSpec { command_name: "B", with: &[], compensates: Some(&DISPATCH_RB) }],
|
|
1179
|
+
},
|
|
1180
|
+
Handler {
|
|
1181
|
+
event_type: "BEvent",
|
|
1182
|
+
from_state: "state2",
|
|
1183
|
+
to_state: "state3",
|
|
1184
|
+
dispatches: &[DispatchSpec { command_name: "C", with: &[], compensates: None }],
|
|
1185
|
+
},
|
|
1186
|
+
Handler { event_type: REFUSED, from_state: "state3", to_state: "compensated", dispatches: &[] },
|
|
1187
|
+
];
|
|
1188
|
+
|
|
1189
|
+
static PROCESS_MANAGERS: &[ProcessManagerDef] = &[ProcessManagerDef {
|
|
1190
|
+
name: "TestMultiLeg",
|
|
1191
|
+
correlates_by: "id",
|
|
1192
|
+
starts_on: "Started",
|
|
1193
|
+
ends_on: "NeverHappens",
|
|
1194
|
+
initial_state: "start",
|
|
1195
|
+
handlers: HANDLERS,
|
|
1196
|
+
}];
|
|
1197
|
+
static POLICIES: &[PolicyRule] = &[];
|
|
1198
|
+
static CROSS_DOMAIN_POLICIES: &[CrossDomainPolicyRule] = &[];
|
|
1199
|
+
static QUERIES: &[crate::kernel::QueryDef] = &[];
|
|
1200
|
+
|
|
1201
|
+
let tables = Tables {
|
|
1202
|
+
policies: POLICIES,
|
|
1203
|
+
cross_domain_policies: CROSS_DOMAIN_POLICIES,
|
|
1204
|
+
process_managers: PROCESS_MANAGERS,
|
|
1205
|
+
reference_key_fn: multi_leg_no_reference_key,
|
|
1206
|
+
queries: QUERIES,
|
|
1207
|
+
command_creates_fn: multi_leg_always_creates,
|
|
1208
|
+
identity_head_fn: multi_leg_no_identity_head,
|
|
1209
|
+
command_attributes_fn: multi_leg_no_declared_attributes,
|
|
1210
|
+
entity_identity_head_fn: multi_leg_no_entity_identity_head,
|
|
1211
|
+
};
|
|
1212
|
+
|
|
1213
|
+
let mut store = MultiLegTestStore;
|
|
1214
|
+
let mut sagas: HashMap<(String, String), SagaInstance> = HashMap::new();
|
|
1215
|
+
let mut all_events = Vec::new();
|
|
1216
|
+
let mut mutations = Vec::new();
|
|
1217
|
+
let mut cross_domain = Vec::new();
|
|
1218
|
+
let mut reaction_log = Vec::new();
|
|
1219
|
+
let mut saga_log: Vec<Json> = Vec::new();
|
|
1220
|
+
|
|
1221
|
+
let outcome = orchestrate(
|
|
1222
|
+
&mut store,
|
|
1223
|
+
multi_leg_test_dispatch,
|
|
1224
|
+
tables,
|
|
1225
|
+
&mut sagas,
|
|
1226
|
+
"Test::Kickoff",
|
|
1227
|
+
&Json::Object(vec![]),
|
|
1228
|
+
None,
|
|
1229
|
+
None,
|
|
1230
|
+
None,
|
|
1231
|
+
None,
|
|
1232
|
+
0,
|
|
1233
|
+
&mut all_events,
|
|
1234
|
+
&mut mutations,
|
|
1235
|
+
&mut cross_domain,
|
|
1236
|
+
&mut reaction_log,
|
|
1237
|
+
&mut saga_log,
|
|
1238
|
+
);
|
|
1239
|
+
assert!(outcome.is_ok(), "top-level Kickoff should not itself refuse: {outcome:?}");
|
|
1240
|
+
|
|
1241
|
+
let key = ("TestMultiLeg".to_string(), "corr-1".to_string());
|
|
1242
|
+
let instance = sagas.get(&key).expect("the saga instance should still exist (it never reaches ends_on)");
|
|
1243
|
+
assert_eq!(instance.state, "compensated", "the saga should have unwound to the on-:refused leg's own to_state");
|
|
1244
|
+
assert!(instance.completed_compensations.is_empty(), "the ledger should be fully drained after compensate runs");
|
|
1245
|
+
|
|
1246
|
+
let dispatch_entries: Vec<(String, bool, bool)> = saga_log
|
|
1247
|
+
.iter()
|
|
1248
|
+
.filter_map(|entry| {
|
|
1249
|
+
let dispatch = entry.get("dispatch")?.as_str()?.to_string();
|
|
1250
|
+
let delivered = matches!(entry.get("delivered"), Some(Json::Bool(true)));
|
|
1251
|
+
let compensation = matches!(entry.get("compensation"), Some(Json::Bool(true)));
|
|
1252
|
+
Some((dispatch, delivered, compensation))
|
|
1253
|
+
})
|
|
1254
|
+
.collect();
|
|
1255
|
+
|
|
1256
|
+
// The order is the assertion: C refuses, RB then RA fire newest-first, then B and A log
|
|
1257
|
+
// delivered once their cascades return. It holds only if the ledger was filled before
|
|
1258
|
+
// `compensate` drained it.
|
|
1259
|
+
assert_eq!(
|
|
1260
|
+
dispatch_entries,
|
|
1261
|
+
vec![
|
|
1262
|
+
("C".to_string(), false, false),
|
|
1263
|
+
("RB".to_string(), true, true),
|
|
1264
|
+
("RA".to_string(), true, true),
|
|
1265
|
+
("B".to_string(), true, false),
|
|
1266
|
+
("A".to_string(), true, false),
|
|
1267
|
+
],
|
|
1268
|
+
"expected C to refuse, then RB and RA to fire as derived compensations newest-first, \
|
|
1269
|
+
then B's and A's own forward dispatches to be logged delivered once their \
|
|
1270
|
+
downstream cascade returns — saga_log was: {saga_log:?}"
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Pins domain-qualifying a same-domain entity command (`Manifest::Slot.Fill`): its leftover
|
|
1275
|
+
// `::` looks cross-domain, and an unqualified name would fail as an unknown command. The fake
|
|
1276
|
+
// dispatch panics on any verb but the qualified, dot-joined form.
|
|
1277
|
+
fn entity_command_test_dispatch(
|
|
1278
|
+
_store: &mut MultiLegTestStore,
|
|
1279
|
+
verb: &str,
|
|
1280
|
+
_args: &Json,
|
|
1281
|
+
_caller_role: Option<&str>,
|
|
1282
|
+
_caller_actor_id: Option<&str>,
|
|
1283
|
+
_mutations: &mut Vec<MutationRecord>,
|
|
1284
|
+
) -> Result<Vec<Event>, Refusal> {
|
|
1285
|
+
let plain_event = |name: &str| Event {
|
|
1286
|
+
name: name.to_string(),
|
|
1287
|
+
aggregate: "Test::Manifest".to_string(),
|
|
1288
|
+
id: "m1".to_string(),
|
|
1289
|
+
payload: Json::Object(vec![]),
|
|
1290
|
+
occurred_at: None,
|
|
1291
|
+
correlation: None,
|
|
1292
|
+
};
|
|
1293
|
+
match verb {
|
|
1294
|
+
"Test::Kickoff" => Ok(vec![Event {
|
|
1295
|
+
name: "Started".to_string(),
|
|
1296
|
+
aggregate: "Test::Manifest".to_string(),
|
|
1297
|
+
id: "m1".to_string(),
|
|
1298
|
+
payload: Json::obj(vec![("id", Json::str("corr-1"))]),
|
|
1299
|
+
occurred_at: None,
|
|
1300
|
+
correlation: None,
|
|
1301
|
+
}]),
|
|
1302
|
+
// The qualified, dot-joined shape a generated `registry.rs` arm uses.
|
|
1303
|
+
"Test::Manifest.Slot.Fill" => Ok(vec![plain_event("SlotFilled")]),
|
|
1304
|
+
other => panic!("unexpected verb in entity-command qualification test (BUG#9): {other}"),
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
#[test]
|
|
1309
|
+
fn a_saga_leg_dispatching_a_same_domain_entity_command_is_domain_qualified_and_dot_folded() {
|
|
1310
|
+
static HANDLERS: &[Handler] = &[
|
|
1311
|
+
Handler {
|
|
1312
|
+
event_type: "Started",
|
|
1313
|
+
from_state: "start",
|
|
1314
|
+
to_state: "filling",
|
|
1315
|
+
// Bare on the wire, entity-nested.
|
|
1316
|
+
dispatches: &[DispatchSpec { command_name: "Manifest::Slot.Fill", with: &[], compensates: None }],
|
|
1317
|
+
},
|
|
1318
|
+
];
|
|
1319
|
+
|
|
1320
|
+
static PROCESS_MANAGERS: &[ProcessManagerDef] = &[ProcessManagerDef {
|
|
1321
|
+
name: "TestPacking",
|
|
1322
|
+
correlates_by: "id",
|
|
1323
|
+
starts_on: "Started",
|
|
1324
|
+
ends_on: "NeverHappens",
|
|
1325
|
+
initial_state: "start",
|
|
1326
|
+
handlers: HANDLERS,
|
|
1327
|
+
}];
|
|
1328
|
+
static POLICIES: &[PolicyRule] = &[];
|
|
1329
|
+
static CROSS_DOMAIN_POLICIES: &[CrossDomainPolicyRule] = &[];
|
|
1330
|
+
static QUERIES: &[crate::kernel::QueryDef] = &[];
|
|
1331
|
+
|
|
1332
|
+
let tables = Tables {
|
|
1333
|
+
policies: POLICIES,
|
|
1334
|
+
cross_domain_policies: CROSS_DOMAIN_POLICIES,
|
|
1335
|
+
process_managers: PROCESS_MANAGERS,
|
|
1336
|
+
reference_key_fn: multi_leg_no_reference_key,
|
|
1337
|
+
queries: QUERIES,
|
|
1338
|
+
command_creates_fn: multi_leg_always_creates,
|
|
1339
|
+
identity_head_fn: multi_leg_no_identity_head,
|
|
1340
|
+
command_attributes_fn: multi_leg_no_declared_attributes,
|
|
1341
|
+
entity_identity_head_fn: multi_leg_no_entity_identity_head,
|
|
1342
|
+
};
|
|
1343
|
+
|
|
1344
|
+
let mut store = MultiLegTestStore;
|
|
1345
|
+
let mut sagas: HashMap<(String, String), SagaInstance> = HashMap::new();
|
|
1346
|
+
let mut all_events = Vec::new();
|
|
1347
|
+
let mut mutations = Vec::new();
|
|
1348
|
+
let mut cross_domain = Vec::new();
|
|
1349
|
+
let mut reaction_log = Vec::new();
|
|
1350
|
+
let mut saga_log: Vec<Json> = Vec::new();
|
|
1351
|
+
|
|
1352
|
+
let outcome = orchestrate(
|
|
1353
|
+
&mut store,
|
|
1354
|
+
entity_command_test_dispatch,
|
|
1355
|
+
tables,
|
|
1356
|
+
&mut sagas,
|
|
1357
|
+
"Test::Kickoff",
|
|
1358
|
+
&Json::Object(vec![]),
|
|
1359
|
+
None,
|
|
1360
|
+
None,
|
|
1361
|
+
None,
|
|
1362
|
+
None,
|
|
1363
|
+
0,
|
|
1364
|
+
&mut all_events,
|
|
1365
|
+
&mut mutations,
|
|
1366
|
+
&mut cross_domain,
|
|
1367
|
+
&mut reaction_log,
|
|
1368
|
+
&mut saga_log,
|
|
1369
|
+
);
|
|
1370
|
+
assert!(outcome.is_ok(), "top-level Kickoff should not itself refuse: {outcome:?}");
|
|
1371
|
+
|
|
1372
|
+
let dispatch_entries: Vec<(String, bool)> = saga_log
|
|
1373
|
+
.iter()
|
|
1374
|
+
.filter_map(|entry| {
|
|
1375
|
+
let dispatch = entry.get("dispatch")?.as_str()?.to_string();
|
|
1376
|
+
let delivered = matches!(entry.get("delivered"), Some(Json::Bool(true)));
|
|
1377
|
+
Some((dispatch, delivered))
|
|
1378
|
+
})
|
|
1379
|
+
.collect();
|
|
1380
|
+
|
|
1381
|
+
assert_eq!(
|
|
1382
|
+
dispatch_entries,
|
|
1383
|
+
vec![("Manifest::Slot.Fill".to_string(), true)],
|
|
1384
|
+
"the entity-owned command's own saga leg should have been correctly domain-qualified \
|
|
1385
|
+
and delivered — not refused \"unknown command\" the way the pre-BUG#9 heuristic left \
|
|
1386
|
+
it — saga_log was: {saga_log:?}"
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
#[test]
|
|
1391
|
+
fn qualify_saga_command_name_folds_a_same_domain_entity_command_reference() {
|
|
1392
|
+
assert_eq!(qualify_saga_command_name("Waybill", "Manifest::Slot.Fill"), "Waybill::Manifest.Slot.Fill");
|
|
1393
|
+
assert_eq!(qualify_saga_command_name("Waybill", "Manifest::Slot.Clear"), "Waybill::Manifest.Slot.Clear");
|
|
1394
|
+
// A plain aggregate command is only domain-prefixed.
|
|
1395
|
+
assert_eq!(qualify_saga_command_name("Waybill", "Manifest.Open"), "Waybill::Manifest.Open");
|
|
1396
|
+
assert_eq!(qualify_saga_command_name("Waybill", "Manifest.AddSlot"), "Waybill::Manifest.AddSlot");
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// Fixture: non-creating `Widgets::Ledger.Grant`, identity head `email`, declared attribute
|
|
1400
|
+
// `role`.
|
|
1401
|
+
fn routed_identity_head(aggregate: &str) -> Option<&'static str> {
|
|
1402
|
+
(aggregate == "Widgets::Ledger").then_some("email")
|
|
1403
|
+
}
|
|
1404
|
+
fn routed_declared_attributes(verb: &str) -> &'static [&'static str] {
|
|
1405
|
+
if verb == "Widgets::Ledger.Grant" {
|
|
1406
|
+
&["role"]
|
|
1407
|
+
} else {
|
|
1408
|
+
&[]
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
fn routed_reference_key_never_found(_aggregate: &str) -> Option<&'static str> {
|
|
1412
|
+
None
|
|
1413
|
+
}
|
|
1414
|
+
fn routed_entity_identity_head_never_found(_qualified_path: &str) -> Option<&'static str> {
|
|
1415
|
+
None
|
|
1416
|
+
}
|
|
1417
|
+
fn routed_never_creates(_verb: &str) -> bool {
|
|
1418
|
+
false
|
|
1419
|
+
}
|
|
1420
|
+
fn routed_fixture_tables() -> Tables<'static> {
|
|
1421
|
+
Tables {
|
|
1422
|
+
policies: &[],
|
|
1423
|
+
cross_domain_policies: &[],
|
|
1424
|
+
process_managers: &[],
|
|
1425
|
+
reference_key_fn: routed_reference_key_never_found,
|
|
1426
|
+
queries: &[],
|
|
1427
|
+
command_creates_fn: routed_never_creates,
|
|
1428
|
+
identity_head_fn: routed_identity_head,
|
|
1429
|
+
command_attributes_fn: routed_declared_attributes,
|
|
1430
|
+
entity_identity_head_fn: routed_entity_identity_head_never_found,
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
fn routed_to_a_receiver(id: &str) -> Json {
|
|
1435
|
+
Json::obj(vec![("to", Json::str(id)), ("with", Json::obj(vec![("role", Json::str("Admin"))]))])
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
#[test]
|
|
1439
|
+
fn split_routed_args_routes_a_value_object_identity_as_the_receiver() {
|
|
1440
|
+
// A value-object identity is `{"value": ...}` in an event payload and forwards as that
|
|
1441
|
+
// object.
|
|
1442
|
+
let projected = Json::obj(vec![("email", Json::obj(vec![("value", Json::str("a@b.co"))])), ("role", Json::str("Admin"))]);
|
|
1443
|
+
|
|
1444
|
+
let routed = split_routed_args(projected, "Widgets::Ledger.Grant", &routed_fixture_tables());
|
|
1445
|
+
|
|
1446
|
+
assert_eq!(routed, routed_to_a_receiver("a@b.co"), "a VO identity should route as `to` — got {routed:?}");
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
#[test]
|
|
1450
|
+
fn split_routed_args_still_routes_a_bare_scalar_identity() {
|
|
1451
|
+
let projected = Json::obj(vec![("email", Json::str("a@b.co")), ("role", Json::str("Admin"))]);
|
|
1452
|
+
|
|
1453
|
+
let routed = split_routed_args(projected, "Widgets::Ledger.Grant", &routed_fixture_tables());
|
|
1454
|
+
|
|
1455
|
+
assert_eq!(routed, routed_to_a_receiver("a@b.co"), "a scalar identity should route as `to` — got {routed:?}");
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
#[test]
|
|
1459
|
+
fn split_routed_args_leaves_the_args_flat_when_the_identity_is_empty_or_not_a_value() {
|
|
1460
|
+
let flat = Json::obj(vec![("role", Json::str("Admin"))]);
|
|
1461
|
+
let empty = Json::obj(vec![("email", Json::obj(vec![("value", Json::str(""))])), ("role", Json::str("Admin"))]);
|
|
1462
|
+
let unrelated = Json::obj(vec![("email", Json::obj(vec![("other", Json::str("a@b.co"))])), ("role", Json::str("Admin"))]);
|
|
1463
|
+
|
|
1464
|
+
for projected in [empty, unrelated] {
|
|
1465
|
+
let routed = split_routed_args(projected, "Widgets::Ledger.Grant", &routed_fixture_tables());
|
|
1466
|
+
assert_eq!(routed, flat, "an unusable identity should leave only the declared facts — got {routed:?}");
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
#[test]
|
|
1471
|
+
fn entity_command_paths_only_matches_a_one_level_deep_entity_command() {
|
|
1472
|
+
assert_eq!(
|
|
1473
|
+
entity_command_paths("Waybill::Manifest.Slot.Fill"),
|
|
1474
|
+
Some(("Waybill::Manifest", "Waybill::Manifest.Slot".to_string()))
|
|
1475
|
+
);
|
|
1476
|
+
assert_eq!(entity_command_paths("Waybill::Manifest.AddSlot"), None);
|
|
1477
|
+
// A two-level entity command is deliberately not routed.
|
|
1478
|
+
assert_eq!(entity_command_paths("Domain::Workspace.Board.Card.Annotate"), None);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// Fixture: one-level entity command `Widgets::Crate.Slot.Fill` whose `with:` carries the
|
|
1482
|
+
// entity's identity (`number`) but not the parent aggregate's.
|
|
1483
|
+
fn bug10_entity_identity_head(qualified_path: &str) -> Option<&'static str> {
|
|
1484
|
+
(qualified_path == "Widgets::Crate.Slot").then_some("number")
|
|
1485
|
+
}
|
|
1486
|
+
fn bug10_declared_attributes(verb: &str) -> &'static [&'static str] {
|
|
1487
|
+
if verb == "Widgets::Crate.Slot.Fill" {
|
|
1488
|
+
&["item"]
|
|
1489
|
+
} else {
|
|
1490
|
+
&[]
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
fn bug10_identity_head_never_found(_aggregate: &str) -> Option<&'static str> {
|
|
1494
|
+
None
|
|
1495
|
+
}
|
|
1496
|
+
fn bug10_reference_key_never_found(_aggregate: &str) -> Option<&'static str> {
|
|
1497
|
+
None
|
|
1498
|
+
}
|
|
1499
|
+
fn bug10_never_creates(_verb: &str) -> bool {
|
|
1500
|
+
false
|
|
1501
|
+
}
|
|
1502
|
+
fn bug10_fixture_tables() -> Tables<'static> {
|
|
1503
|
+
Tables {
|
|
1504
|
+
policies: &[],
|
|
1505
|
+
cross_domain_policies: &[],
|
|
1506
|
+
process_managers: &[],
|
|
1507
|
+
reference_key_fn: bug10_reference_key_never_found,
|
|
1508
|
+
queries: &[],
|
|
1509
|
+
command_creates_fn: bug10_never_creates,
|
|
1510
|
+
identity_head_fn: bug10_identity_head_never_found,
|
|
1511
|
+
command_attributes_fn: bug10_declared_attributes,
|
|
1512
|
+
entity_identity_head_fn: bug10_entity_identity_head,
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
#[test]
|
|
1517
|
+
fn route_dispatch_args_threads_the_triggering_events_aggregate_identity_into_a_one_level_entity_commands_route() {
|
|
1518
|
+
// `number` is VO-nested; the parent aggregate's identity comes only from the triggering
|
|
1519
|
+
// event.
|
|
1520
|
+
let tables = bug10_fixture_tables();
|
|
1521
|
+
let projected = Json::obj(vec![("number", Json::obj(vec![("value", Json::int(7))])), ("item", Json::str("hello"))]);
|
|
1522
|
+
let event = Event {
|
|
1523
|
+
name: "SlotAdded".to_string(),
|
|
1524
|
+
aggregate: "Widgets::Crate".to_string(),
|
|
1525
|
+
id: "crate-1".to_string(),
|
|
1526
|
+
payload: Json::Object(vec![]),
|
|
1527
|
+
occurred_at: None,
|
|
1528
|
+
correlation: None,
|
|
1529
|
+
};
|
|
1530
|
+
|
|
1531
|
+
let routed = route_dispatch_args(projected, "Widgets::Crate.Slot.Fill", &tables, &event);
|
|
1532
|
+
assert_eq!(
|
|
1533
|
+
routed,
|
|
1534
|
+
Json::obj(vec![
|
|
1535
|
+
("to", Json::obj(vec![("aggregate", Json::str("crate-1")), ("entities", Json::Array(vec![Json::str("7")]))])),
|
|
1536
|
+
("with", Json::obj(vec![("item", Json::str("hello"))])),
|
|
1537
|
+
]),
|
|
1538
|
+
"should route to {{aggregate: crate-1, entities: [7]}} with facts sliced to Fill's own \
|
|
1539
|
+
declared attributes, mirroring Ruby's ReactionInvocation.build/source_receiver_for — \
|
|
1540
|
+
got {routed:?}"
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
#[test]
|
|
1545
|
+
fn route_dispatch_args_falls_through_unchanged_when_the_triggering_event_is_a_different_aggregate() {
|
|
1546
|
+
// The triggering event is a different aggregate, so no receiver may be invented: falls
|
|
1547
|
+
// through unrouted.
|
|
1548
|
+
let tables = bug10_fixture_tables();
|
|
1549
|
+
let projected = Json::obj(vec![("number", Json::obj(vec![("value", Json::int(7))])), ("item", Json::str("hello"))]);
|
|
1550
|
+
let event = Event {
|
|
1551
|
+
name: "SomethingElseHappened".to_string(),
|
|
1552
|
+
aggregate: "Widgets::OtherThing".to_string(),
|
|
1553
|
+
id: "other-1".to_string(),
|
|
1554
|
+
payload: Json::Object(vec![]),
|
|
1555
|
+
occurred_at: None,
|
|
1556
|
+
correlation: None,
|
|
1557
|
+
};
|
|
1558
|
+
|
|
1559
|
+
let routed = route_dispatch_args(projected, "Widgets::Crate.Slot.Fill", &tables, &event);
|
|
1560
|
+
assert_eq!(
|
|
1561
|
+
routed,
|
|
1562
|
+
Json::obj(vec![("item", Json::str("hello"))]),
|
|
1563
|
+
"with no aggregate identity anywhere (not in the projected facts, and the triggering \
|
|
1564
|
+
event names a different aggregate entirely), this must fall through to split_routed_\
|
|
1565
|
+
args's own unrouted, declared-attributes-only answer, never invent a receiver — \
|
|
1566
|
+
got {routed:?}"
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// Two legs on one event from different states, each reached exactly when its `from:` is
|
|
1571
|
+
// current.
|
|
1572
|
+
fn two_leg_test_dispatch(
|
|
1573
|
+
_store: &mut MultiLegTestStore,
|
|
1574
|
+
verb: &str,
|
|
1575
|
+
_args: &Json,
|
|
1576
|
+
_caller_role: Option<&str>,
|
|
1577
|
+
_caller_actor_id: Option<&str>,
|
|
1578
|
+
_mutations: &mut Vec<MutationRecord>,
|
|
1579
|
+
) -> Result<Vec<Event>, Refusal> {
|
|
1580
|
+
let event = |name: &str| Event {
|
|
1581
|
+
name: name.to_string(),
|
|
1582
|
+
aggregate: "Test::Parcel".to_string(),
|
|
1583
|
+
id: "p1".to_string(),
|
|
1584
|
+
payload: Json::obj(vec![("id", Json::str("p1"))]),
|
|
1585
|
+
occurred_at: None,
|
|
1586
|
+
correlation: None,
|
|
1587
|
+
};
|
|
1588
|
+
match verb {
|
|
1589
|
+
"Test::Post" => Ok(vec![event("Posted"), event("Scanned"), event("Scanned")]),
|
|
1590
|
+
"Test::Deliver" => Ok(vec![event("Delivered")]),
|
|
1591
|
+
other => panic!("unexpected verb in two-leg selection test: {other}"),
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
#[test]
|
|
1596
|
+
fn a_saga_leg_is_selected_by_event_and_current_state_so_two_legs_on_one_event_are_both_reachable() {
|
|
1597
|
+
static HANDLERS: &[Handler] = &[
|
|
1598
|
+
Handler { event_type: "Scanned", from_state: "posted", to_state: "picked_up", dispatches: &[] },
|
|
1599
|
+
Handler {
|
|
1600
|
+
event_type: "Scanned",
|
|
1601
|
+
from_state: "picked_up",
|
|
1602
|
+
to_state: "handed_over",
|
|
1603
|
+
dispatches: &[DispatchSpec { command_name: "Deliver", with: &[], compensates: None }],
|
|
1604
|
+
},
|
|
1605
|
+
];
|
|
1606
|
+
static PROCESS_MANAGERS: &[ProcessManagerDef] = &[ProcessManagerDef {
|
|
1607
|
+
name: "Delivery",
|
|
1608
|
+
correlates_by: "id",
|
|
1609
|
+
starts_on: "Posted",
|
|
1610
|
+
ends_on: "Delivered",
|
|
1611
|
+
initial_state: "posted",
|
|
1612
|
+
handlers: HANDLERS,
|
|
1613
|
+
}];
|
|
1614
|
+
static POLICIES: &[PolicyRule] = &[];
|
|
1615
|
+
static CROSS_DOMAIN_POLICIES: &[CrossDomainPolicyRule] = &[];
|
|
1616
|
+
static QUERIES: &[crate::kernel::QueryDef] = &[];
|
|
1617
|
+
|
|
1618
|
+
let tables = Tables {
|
|
1619
|
+
policies: POLICIES,
|
|
1620
|
+
cross_domain_policies: CROSS_DOMAIN_POLICIES,
|
|
1621
|
+
process_managers: PROCESS_MANAGERS,
|
|
1622
|
+
reference_key_fn: multi_leg_no_reference_key,
|
|
1623
|
+
queries: QUERIES,
|
|
1624
|
+
command_creates_fn: multi_leg_always_creates,
|
|
1625
|
+
identity_head_fn: multi_leg_no_identity_head,
|
|
1626
|
+
command_attributes_fn: multi_leg_no_declared_attributes,
|
|
1627
|
+
entity_identity_head_fn: multi_leg_no_entity_identity_head,
|
|
1628
|
+
};
|
|
1629
|
+
|
|
1630
|
+
let mut store = MultiLegTestStore;
|
|
1631
|
+
let mut sagas: HashMap<(String, String), SagaInstance> = HashMap::new();
|
|
1632
|
+
let mut all_events = Vec::new();
|
|
1633
|
+
let mut mutations = Vec::new();
|
|
1634
|
+
let mut cross_domain = Vec::new();
|
|
1635
|
+
let mut reaction_log = Vec::new();
|
|
1636
|
+
let mut saga_log: Vec<Json> = Vec::new();
|
|
1637
|
+
|
|
1638
|
+
let outcome = orchestrate(
|
|
1639
|
+
&mut store,
|
|
1640
|
+
two_leg_test_dispatch,
|
|
1641
|
+
tables,
|
|
1642
|
+
&mut sagas,
|
|
1643
|
+
"Test::Post",
|
|
1644
|
+
&Json::Object(vec![]),
|
|
1645
|
+
None,
|
|
1646
|
+
None,
|
|
1647
|
+
None,
|
|
1648
|
+
None,
|
|
1649
|
+
0,
|
|
1650
|
+
&mut all_events,
|
|
1651
|
+
&mut mutations,
|
|
1652
|
+
&mut cross_domain,
|
|
1653
|
+
&mut reaction_log,
|
|
1654
|
+
&mut saga_log,
|
|
1655
|
+
);
|
|
1656
|
+
assert!(outcome.is_ok(), "Post should not refuse: {outcome:?}");
|
|
1657
|
+
|
|
1658
|
+
let advances: Vec<(String, String)> = saga_log
|
|
1659
|
+
.iter()
|
|
1660
|
+
.filter_map(|entry| {
|
|
1661
|
+
if !matches!(entry.get("advanced"), Some(Json::Bool(true))) {
|
|
1662
|
+
return None;
|
|
1663
|
+
}
|
|
1664
|
+
Some((
|
|
1665
|
+
entry.get("from").and_then(Json::as_str).unwrap_or("").to_string(),
|
|
1666
|
+
entry.get("to").and_then(Json::as_str).unwrap_or("").to_string(),
|
|
1667
|
+
))
|
|
1668
|
+
})
|
|
1669
|
+
.collect();
|
|
1670
|
+
assert_eq!(
|
|
1671
|
+
advances,
|
|
1672
|
+
vec![("posted".to_string(), "picked_up".to_string()), ("picked_up".to_string(), "handed_over".to_string())],
|
|
1673
|
+
"the second Scanned must reach the second leg, not re-find the first — saga_log was: {saga_log:?}"
|
|
1674
|
+
);
|
|
1675
|
+
assert!(
|
|
1676
|
+
all_events.iter().any(|e| e.name == "Delivered"),
|
|
1677
|
+
"the second leg's own dispatch must have run — events were: {all_events:?}"
|
|
1678
|
+
);
|
|
1679
|
+
assert!(
|
|
1680
|
+
!sagas.contains_key(&("Delivery".to_string(), "p1".to_string())),
|
|
1681
|
+
"Delivered is ends_on — the instance should have been retired"
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// One dispatch announces two events: a saga leg answers the first, a policy the second. The
|
|
1686
|
+
// whole batch is logged before any reaction, then reactions run per event in emits order.
|
|
1687
|
+
fn two_event_test_dispatch(
|
|
1688
|
+
_store: &mut MultiLegTestStore,
|
|
1689
|
+
verb: &str,
|
|
1690
|
+
_args: &Json,
|
|
1691
|
+
_caller_role: Option<&str>,
|
|
1692
|
+
_caller_actor_id: Option<&str>,
|
|
1693
|
+
_mutations: &mut Vec<MutationRecord>,
|
|
1694
|
+
) -> Result<Vec<Event>, Refusal> {
|
|
1695
|
+
let event = |name: &str| Event {
|
|
1696
|
+
name: name.to_string(),
|
|
1697
|
+
aggregate: "Test::Run".to_string(),
|
|
1698
|
+
id: "r1".to_string(),
|
|
1699
|
+
payload: Json::obj(vec![("id", Json::str("r1"))]),
|
|
1700
|
+
occurred_at: None,
|
|
1701
|
+
correlation: None,
|
|
1702
|
+
};
|
|
1703
|
+
match verb {
|
|
1704
|
+
"Test::Start" => Ok(vec![event("Started")]),
|
|
1705
|
+
"Test::Advance" => Ok(vec![event("Legged"), event("Reported")]),
|
|
1706
|
+
"Test::Log" => Ok(vec![event("Logged")]),
|
|
1707
|
+
"Test::Note" => Ok(vec![event("Noted")]),
|
|
1708
|
+
other => panic!("unexpected verb in two-event ordering test: {other}"),
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
#[test]
|
|
1713
|
+
fn a_batch_is_logged_whole_then_reacted_to_per_event_policies_before_sagas() {
|
|
1714
|
+
static HANDLERS: &[Handler] = &[Handler {
|
|
1715
|
+
event_type: "Legged",
|
|
1716
|
+
from_state: "started",
|
|
1717
|
+
to_state: "logged",
|
|
1718
|
+
dispatches: &[DispatchSpec { command_name: "Log", with: &[], compensates: None }],
|
|
1719
|
+
}];
|
|
1720
|
+
static PROCESS_MANAGERS: &[ProcessManagerDef] = &[ProcessManagerDef {
|
|
1721
|
+
name: "Route",
|
|
1722
|
+
correlates_by: "id",
|
|
1723
|
+
starts_on: "Started",
|
|
1724
|
+
ends_on: "Logged",
|
|
1725
|
+
initial_state: "started",
|
|
1726
|
+
handlers: HANDLERS,
|
|
1727
|
+
}];
|
|
1728
|
+
static POLICIES: &[PolicyRule] = &[PolicyRule {
|
|
1729
|
+
policy_name: "Audit",
|
|
1730
|
+
event_name: "Reported",
|
|
1731
|
+
event_qualifier: None,
|
|
1732
|
+
target_verb: "Test::Note",
|
|
1733
|
+
where_expr: None,
|
|
1734
|
+
for_each: None,
|
|
1735
|
+
for_each_key: None,
|
|
1736
|
+
with_spec: &[],
|
|
1737
|
+
}];
|
|
1738
|
+
static CROSS_DOMAIN_POLICIES: &[CrossDomainPolicyRule] = &[];
|
|
1739
|
+
static QUERIES: &[crate::kernel::QueryDef] = &[];
|
|
1740
|
+
|
|
1741
|
+
let tables = Tables {
|
|
1742
|
+
policies: POLICIES,
|
|
1743
|
+
cross_domain_policies: CROSS_DOMAIN_POLICIES,
|
|
1744
|
+
process_managers: PROCESS_MANAGERS,
|
|
1745
|
+
reference_key_fn: multi_leg_no_reference_key,
|
|
1746
|
+
queries: QUERIES,
|
|
1747
|
+
command_creates_fn: multi_leg_always_creates,
|
|
1748
|
+
identity_head_fn: multi_leg_no_identity_head,
|
|
1749
|
+
command_attributes_fn: multi_leg_no_declared_attributes,
|
|
1750
|
+
entity_identity_head_fn: multi_leg_no_entity_identity_head,
|
|
1751
|
+
};
|
|
1752
|
+
|
|
1753
|
+
let mut store = MultiLegTestStore;
|
|
1754
|
+
let mut sagas: HashMap<(String, String), SagaInstance> = HashMap::new();
|
|
1755
|
+
let mut all_events = Vec::new();
|
|
1756
|
+
let mut mutations = Vec::new();
|
|
1757
|
+
let mut cross_domain = Vec::new();
|
|
1758
|
+
let mut reaction_log = Vec::new();
|
|
1759
|
+
let mut saga_log: Vec<Json> = Vec::new();
|
|
1760
|
+
|
|
1761
|
+
for verb in ["Test::Start", "Test::Advance"] {
|
|
1762
|
+
let outcome = orchestrate(
|
|
1763
|
+
&mut store,
|
|
1764
|
+
two_event_test_dispatch,
|
|
1765
|
+
tables,
|
|
1766
|
+
&mut sagas,
|
|
1767
|
+
verb,
|
|
1768
|
+
&Json::Object(vec![]),
|
|
1769
|
+
None,
|
|
1770
|
+
None,
|
|
1771
|
+
None,
|
|
1772
|
+
None,
|
|
1773
|
+
0,
|
|
1774
|
+
&mut all_events,
|
|
1775
|
+
&mut mutations,
|
|
1776
|
+
&mut cross_domain,
|
|
1777
|
+
&mut reaction_log,
|
|
1778
|
+
&mut saga_log,
|
|
1779
|
+
);
|
|
1780
|
+
assert!(outcome.is_ok(), "{verb} should not refuse: {outcome:?}");
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
let names: Vec<&str> = all_events.iter().map(|e| e.name.as_str()).collect();
|
|
1784
|
+
assert_eq!(
|
|
1785
|
+
names,
|
|
1786
|
+
vec!["Started", "Legged", "Reported", "Logged", "Noted"],
|
|
1787
|
+
"both announced events precede every reaction, and the first event's saga leg precedes the second \
|
|
1788
|
+
event's policy — reaction_log: {reaction_log:?}, saga_log: {saga_log:?}"
|
|
1789
|
+
);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
#[test]
|
|
1793
|
+
fn a_leg_miss_names_every_from_state_the_event_could_have_answered_from() {
|
|
1794
|
+
static HANDLERS: &[Handler] = &[
|
|
1795
|
+
Handler { event_type: "Scanned", from_state: "posted", to_state: "picked_up", dispatches: &[] },
|
|
1796
|
+
Handler { event_type: "Scanned", from_state: "picked_up", to_state: "handed_over", dispatches: &[] },
|
|
1797
|
+
];
|
|
1798
|
+
let pm = ProcessManagerDef {
|
|
1799
|
+
name: "Delivery",
|
|
1800
|
+
correlates_by: "id",
|
|
1801
|
+
starts_on: "Posted",
|
|
1802
|
+
ends_on: "Delivered",
|
|
1803
|
+
initial_state: "posted",
|
|
1804
|
+
handlers: HANDLERS,
|
|
1805
|
+
};
|
|
1806
|
+
assert_eq!(select_leg(&pm, "Scanned", "picked_up").map(|h| h.to_state), Some("handed_over"));
|
|
1807
|
+
assert!(select_leg(&pm, "Scanned", "handed_over").is_none());
|
|
1808
|
+
assert_eq!(leg_mismatch(&pm, "Scanned", "handed_over"), "in \"handed_over\", not \"posted\" or \"picked_up\"");
|
|
1809
|
+
}
|
|
1810
|
+
}
|