hecks 3.14.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/examples/pizzas/adapters/fake_processor.rb +34 -0
- data/examples/pizzas/adapters/payment_signature.rb +66 -0
- data/examples/pizzas/adapters/payment_webhook.rb +87 -0
- data/examples/pizzas/bluebook/pizzas.behaviors +33 -0
- data/examples/pizzas/bluebook/pizzas.hecksagon +5 -3
- data/examples/pizzas/config.ru +22 -0
- data/exe/hecks +1 -1
- data/lib/hecks/adapters/driven/prism.rb +0 -2
- data/lib/hecks/adapters/driven.rb +0 -2
- data/lib/hecks/adapters/driving/json.rb +9 -16
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -2
- data/lib/hecks/bluebook/dsl/derivation_words.rb +58 -0
- data/lib/hecks/bluebook/dsl/translation_builder.rb +6 -37
- data/lib/hecks/bluebook/expression/ast_json.rb +0 -9
- data/lib/hecks/bluebook/hecksagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/translation_judge/rules.rb +7 -10
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +4 -8
- data/lib/hecks/bluebook/meta_validator.rb +0 -11
- data/lib/hecks/bluebook/translation.rb +18 -10
- data/lib/hecks/bluebook/translation_expression/evaluator.rb +63 -0
- data/lib/hecks/bluebook/translation_expression/parser.rb +127 -0
- data/lib/hecks/bluebook/translation_expression.rb +39 -0
- data/lib/hecks/bluebook/translation_plan.rb +70 -0
- data/lib/hecks/bluebook.rb +2 -0
- data/lib/hecks/chapters.rb +3 -3
- data/lib/hecks/cli/project_cli/launcher_source.rb +1 -1
- data/lib/hecks/composer/adapters/claude_composer.adapter +3 -0
- data/lib/hecks/composer/adapters/claude_composer.rb +83 -0
- data/lib/hecks/composer/adapters/fake_agent.adapter +3 -0
- data/lib/hecks/composer/adapters/fake_agent.rb +55 -0
- data/lib/hecks/composer/adapters/jev.adapter +3 -0
- data/lib/hecks/composer/adapters/jev.rb +66 -0
- data/lib/hecks/composer/agent.rb +113 -0
- data/lib/hecks/composer/assistant.rb +110 -0
- data/lib/hecks/composer/behavior_run.rb +53 -0
- data/lib/hecks/composer/bluebook/composer.bluebook +178 -0
- data/lib/hecks/composer/bluebook/composer.hecksagon +16 -0
- data/lib/hecks/composer/board.rb +65 -0
- data/lib/hecks/composer/board_edit/command_text.rb +39 -0
- data/lib/hecks/composer/board_edit/source.rb +84 -0
- data/lib/hecks/composer/board_edit.rb +144 -0
- data/lib/hecks/composer/check.rb +89 -0
- data/lib/hecks/composer/domains.rb +81 -0
- data/lib/hecks/composer/jobs.rb +134 -0
- data/lib/hecks/composer/layout.rb +37 -0
- data/lib/hecks/composer/live.rb +150 -0
- data/lib/hecks/composer/outline.rb +48 -0
- data/lib/hecks/composer/page.html +989 -0
- data/lib/hecks/composer/panels.rb +142 -0
- data/lib/hecks/composer/parse.rb +83 -0
- data/lib/hecks/composer/plain.rb +28 -0
- data/lib/hecks/composer/ports/composer_agent.port +7 -0
- data/lib/hecks/composer/session.rb +141 -0
- data/lib/hecks/composer/toolbox.rb +166 -0
- data/lib/hecks/composer/web.rb +156 -0
- data/lib/hecks/composer/workbench.rb +51 -0
- data/lib/hecks/composer/workspace.rb +81 -0
- data/lib/hecks/composer.rb +17 -0
- data/lib/hecks/corpus/adapters.rb +22 -0
- data/lib/hecks/corpus/rust.rb +1 -1
- data/lib/hecks/corpus/worlds.rb +19 -0
- data/lib/hecks/corpus.rb +5 -0
- data/lib/hecks/forms/app/commands.rb +2 -3
- data/lib/hecks/forms/app/queries.rb +2 -3
- data/lib/hecks/forms/app/records.rb +17 -5
- data/lib/hecks/forms/app/responses.rb +3 -1
- data/lib/hecks/forms/command_form_renderer.rb +42 -16
- data/lib/hecks/forms/field_controls.rb +2 -2
- data/lib/hecks/forms/field_renderer.rb +1 -1
- data/lib/hecks/forms/field_shape.rb +36 -17
- data/lib/hecks/forms/index_renderer.rb +2 -1
- data/lib/hecks/forms/page.rb +1 -1
- data/lib/hecks/forms/params.rb +0 -13
- data/lib/hecks/forms/query_form_renderer.rb +7 -6
- data/lib/hecks/forms/record_renderer.rb +11 -7
- data/lib/hecks/forms/rule_words.rb +100 -0
- data/lib/hecks/forms/value_object_shape.rb +2 -1
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -3
- data/lib/hecks/fuzzing/isolated_boot/worlds.rb +3 -6
- data/lib/hecks/fuzzing/replay/filters.rb +2 -6
- data/lib/hecks/gate/stages.yml +25 -0
- data/lib/hecks/grammar/evolve/seeds.rb +0 -6
- data/lib/hecks/grammar/evolve.rb +0 -3
- data/lib/hecks/grammar/translation.bluebook +1 -1
- data/lib/hecks/hecks/adapters/codebase/composing.rb +56 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
- data/lib/hecks/hecks/adapters/first_admin.rb +3 -3
- data/lib/hecks/hecks/adapters/journal_store/changes.rb +0 -26
- data/lib/hecks/hecks/adapters/journal_store/examination.rb +2 -23
- data/lib/hecks/hecks/adapters/journal_store/readings.rb +3 -11
- data/lib/hecks/hecks/adapters/journal_store.rb +0 -11
- data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +4 -11
- data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +0 -9
- data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +0 -4
- data/lib/hecks/hecks/codebase.behaviors +6 -0
- data/lib/hecks/hecks/codebase.bluebook +21 -0
- data/lib/hecks/hecks/composer.behaviors +68 -0
- data/lib/hecks/hecks/context_map.hecksagon +1 -1
- data/lib/hecks/hecks/custodian.behaviors +0 -6
- data/lib/hecks/hecks/custodian.bluebook +3 -80
- data/lib/hecks/hecks/hecks.hecksagon +2 -2
- data/lib/hecks/hecks/hecks.world +1 -1
- data/lib/hecks/hecks/rehearsal.bluebook +7 -13
- data/lib/hecks/kernel_capabilities/templates.rb +4 -4
- data/lib/hecks/kernel_capabilities.rb +1 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -3
- data/lib/hecks/language/translation/translation_aggregate.bluebook +21 -24
- data/lib/hecks/ports/access_control.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -41
- data/lib/hecks/ports/persistence/plugins/era/lineage/coverage.rb +7 -7
- data/lib/hecks/ports/persistence/plugins/era/lineage/rewrite.rb +25 -2
- data/lib/hecks/ports/persistence/plugins/era/lineage/rules.rb +4 -4
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +17 -25
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/base_sql.rb +0 -12
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/domain_rename.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -22
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +9 -3
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +3 -29
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/refusals.rb +2 -26
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +6 -33
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +2 -4
- data/lib/hecks/ports/persistence/plugins/era/translation/expression_sql.rb +52 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +23 -29
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +3 -6
- data/lib/hecks/ports.rb +0 -1
- data/lib/hecks/projection_files.rb +3 -1
- data/lib/hecks/projections/deploy/templates/lambda/nat_comment_oauth.tmpl +1 -1
- data/lib/hecks/projections/glossary/entries.rb +10 -4
- data/lib/hecks/projections/glossary/groups.rb +42 -0
- data/lib/hecks/projections/glossary/html/page.rb +7 -2
- data/lib/hecks/projections/glossary/html/parser.rb +1 -1
- data/lib/hecks/projections/glossary/html/sections.rb +31 -11
- data/lib/hecks/projections/glossary/html.rb +1 -2
- data/lib/hecks/projections/glossary/markdown.rb +31 -28
- data/lib/hecks/projections/glossary/mermaid.rb +3 -0
- data/lib/hecks/projections/glossary/page.css +143 -153
- data/lib/hecks/projections/glossary/page.js +63 -3
- data/lib/hecks/projections/glossary/sentences/actions.rb +88 -0
- data/lib/hecks/projections/glossary/sentences/paths.rb +77 -0
- data/lib/hecks/projections/glossary/sentences/queries.rb +90 -0
- data/lib/hecks/projections/glossary/sentences/value_objects.rb +32 -5
- data/lib/hecks/projections/glossary/sentences.rb +13 -3
- data/lib/hecks/projections/glossary/slugs.rb +4 -1
- data/lib/hecks/projections/glossary.rb +2 -1
- data/lib/hecks/projections/plain_english.rb +97 -0
- data/lib/hecks/projections/rust_vocabulary/arguments.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary/enum_body.rb +20 -25
- data/lib/hecks/projections/rust_vocabulary/tests.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary.rb +14 -6
- data/lib/hecks/projections/site/cms_editor/color.rb +0 -6
- data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +7 -2
- data/lib/hecks/projections/statements/actions.rb +99 -0
- data/lib/hecks/projections/statements/relationships.rb +86 -0
- data/lib/hecks/projections/statements.rb +15 -24
- data/lib/hecks/projector/exporter/adapters.rb +112 -0
- data/lib/hecks/projector/exporter/translations.rb +18 -35
- data/lib/hecks/projector/exporter/worlds.rb +103 -0
- data/lib/hecks/projector/exporter.rb +5 -1
- data/lib/hecks/quality_control/adapters/agent.rb +6 -7
- data/lib/hecks/quality_control/adapters/agent_profile.rb +16 -114
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +1 -1
- data/lib/hecks/runtime/loader/boot_steps.rb +0 -20
- data/lib/hecks/runtime/outbox.rb +2 -9
- data/lib/hecks/runtime/port_operation_interpreter.rb +1 -9
- data/lib/hecks/runtime/remote_dispatcher.rb +2 -2
- data/lib/hecks/runtime/value/unwrapping.rb +60 -0
- data/lib/hecks/runtime/value.rb +10 -21
- data/lib/hecks/rust_build/project_rust/target_ir.rb +23 -4
- data/lib/hecks/rust_build/wasm.rb +0 -12
- data/lib/hecks/rust_build/write_if_changed.rb +12 -12
- data/lib/hecks/rust_format.rb +77 -0
- data/lib/hecks/tools/fuzz_sweep/findings.rb +3 -9
- data/lib/hecks/tools/fuzz_sweep.rb +1 -3
- data/lib/hecks/tools/translation_audit/verdicts.rb +3 -28
- data/lib/hecks/tools/translation_audit.rb +8 -10
- data/lib/hecks/tools/translation_scaffold/reporting.rb +1 -1
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- data/lib/hecks.rb +3 -0
- data/prebuilt/{1a6f596be124228be93eba1d86dbc9d2933190fa389b284331241cb9f27115a0.marshal → ad68f9e06c8068c2a4176b57abf3083d591d5d8980c2b3e897b97331f965b643.marshal} +0 -0
- data/prebuilt/{a883a7e568667aa5ac904c82daff356bdf9e996252a36af6def4a4a43efb967e.marshal → ee22ec8642c4006717ba2b80f46e5efb81de459eef9dfec043e838c0ad4fd720.marshal} +0 -0
- data/prebuilt/{verdicts-872993b1aa26d73fe48f28251374b8585fed145d02aab42be0f00de386b6ff80.json → verdicts-b69f5dcbba79c20daa0493476fa7c317004a30aa2bc3d3dc6a722ea70ae2b43b.json} +1 -1
- data/rust/Cargo.toml +40 -0
- data/rust/build/Cargo.toml +36 -0
- data/rust/build/clippy.toml +4 -0
- data/rust/build/src/adapters_pass.rs +196 -0
- data/rust/build/src/binding_pass.rs +54 -13
- data/rust/build/src/build_artifact.rs +23 -5
- data/rust/build/src/cargo_sync.rs +69 -26
- data/rust/build/src/lineage_pass.rs +101 -87
- data/rust/build/src/main.rs +11 -2
- data/rust/build/src/pipeline.rs +80 -19
- data/rust/build/src/reserved_names.rs +6 -50
- data/rust/build/src/resolve.rs +82 -35
- data/rust/build/src/rustfmt.rs +100 -0
- data/rust/build/src/seams_pass.rs +85 -16
- data/rust/build/src/subprocess.rs +30 -6
- data/rust/build/src/tmp.rs +5 -2
- data/rust/build/src/translation_pass.rs +72 -22
- data/rust/build/src/world_pass.rs +109 -0
- data/rust/clippy.toml +4 -0
- data/rust/codegen/Cargo.toml +36 -0
- data/rust/codegen/clippy.toml +4 -0
- data/rust/codegen/src/asks.rs +40 -14
- data/rust/codegen/src/attr.rs +2 -2
- data/rust/codegen/src/bridging.rs +676 -117
- data/rust/codegen/src/commands.rs +979 -373
- data/rust/codegen/src/constraints.rs +88 -25
- data/rust/codegen/src/dependency_planning.rs +128 -34
- data/rust/codegen/src/domain_generator.rs +306 -170
- data/rust/codegen/src/exemplar.rs +87 -32
- data/rust/codegen/src/expr_emitter.rs +54 -6
- data/rust/codegen/src/fielded.rs +193 -41
- data/rust/codegen/src/hecks_naming.rs +124 -16
- data/rust/codegen/src/hecks_naming_tests.rs +81 -0
- data/rust/codegen/src/json.rs +80 -44
- data/rust/codegen/src/json_codec.rs +585 -149
- data/rust/codegen/src/literal.rs +104 -138
- data/rust/codegen/src/main.rs +151 -52
- data/rust/codegen/src/manifest.rs +76 -14
- data/rust/codegen/src/mutations.rs +1212 -239
- data/rust/codegen/src/naming.rs +111 -60
- data/rust/codegen/src/optional_pass.rs +90 -28
- data/rust/codegen/src/ports.rs +5 -4
- data/rust/codegen/src/prelude.rs +139 -49
- data/rust/codegen/src/presenters.rs +335 -0
- data/rust/codegen/src/queries.rs +601 -226
- data/rust/codegen/src/reactions.rs +268 -74
- data/rust/codegen/src/read_models.rs +574 -229
- data/rust/codegen/src/reference_specs.rs +16 -3
- data/rust/codegen/src/registry.rs +128 -79
- data/rust/codegen/src/reserved_names.rs +6 -50
- data/rust/codegen/src/shared.rs +71 -22
- data/rust/codegen/src/sidecars.rs +15 -5
- data/rust/codegen/src/skip_reason.rs +8 -2
- data/rust/codegen/src/types.rs +306 -108
- data/rust/host/Cargo.lock +1 -0
- data/rust/host/Cargo.toml +40 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/clippy.toml +4 -0
- data/rust/host/src/api.rs +698 -218
- data/rust/host/src/auth.rs +491 -214
- data/rust/host/src/b64.rs +91 -0
- data/rust/host/src/bin/expr_harness.rs +6 -2
- data/rust/host/src/bin/lineage_harness.rs +47 -11
- data/rust/host/src/bin/mint_harness.rs +70 -37
- data/rust/host/src/bin/validate_harness.rs +16 -8
- data/rust/host/src/boot.rs +144 -68
- data/rust/host/src/boundary_fuzz/api.rs +130 -32
- data/rust/host/src/boundary_fuzz/auth.rs +108 -23
- data/rust/host/src/boundary_fuzz/expr_json.rs +85 -11
- data/rust/host/src/boundary_fuzz/ir.rs +164 -12
- data/rust/host/src/boundary_fuzz/server.rs +283 -36
- data/rust/host/src/boundary_fuzz/web.rs +56 -8
- data/rust/host/src/checkout.rs +78 -32
- data/rust/host/src/clock.rs +8 -0
- data/rust/host/src/commerce_ir.rs +333 -95
- data/rust/host/src/crypto.rs +33 -0
- data/rust/host/src/dispatch.rs +1037 -331
- data/rust/host/src/expr_json.rs +1021 -273
- data/rust/host/src/extension.rs +24 -4
- data/rust/host/src/field_hints.rs +11 -7
- data/rust/host/src/fuzz_support.rs +85 -13
- data/rust/host/src/host.rs +100 -0
- data/rust/host/src/http.rs +22 -0
- data/rust/host/src/ir.rs +326 -16
- data/rust/host/src/journal.rs +277 -176
- data/rust/host/src/lambda_client.rs +219 -53
- data/rust/host/src/lib.rs +16 -4
- data/rust/host/src/log.rs +62 -11
- data/rust/host/src/mint.rs +1153 -326
- data/rust/host/src/needs.rs +185 -44
- data/rust/host/src/payments/keystore.rs +197 -45
- data/rust/host/src/payments/roles.rs +39 -7
- data/rust/host/src/payments/tests.rs +1427 -282
- data/rust/host/src/payments.rs +391 -112
- data/rust/host/src/platform_signin/attack_tests.rs +5 -5
- data/rust/host/src/platform_signin/config.rs +11 -11
- data/rust/host/src/platform_signin/cookie.rs +2 -2
- data/rust/host/src/platform_signin/extension.rs +2 -1
- data/rust/host/src/platform_signin/handler.rs +9 -8
- data/rust/host/src/platform_signin/jti.rs +4 -2
- data/rust/host/src/platform_signin/jwks.rs +2 -1
- data/rust/host/src/platform_signin/jws.rs +2 -37
- data/rust/host/src/platform_signin/known_answer_tests.rs +5 -1
- data/rust/host/src/platform_signin/mode_tests.rs +5 -3
- data/rust/host/src/platform_signin/pages.rs +20 -20
- data/rust/host/src/platform_signin/session.rs +8 -1
- data/rust/host/src/platform_signin/settings.rs +6 -6
- data/rust/host/src/platform_signin/strict_json.rs +12 -9
- data/rust/host/src/platform_signin/verify.rs +41 -41
- data/rust/host/src/platform_signin/wiring_support.rs +13 -18
- data/rust/host/src/presentation.rs +207 -63
- data/rust/host/src/presentation_write.rs +502 -187
- data/rust/host/src/query_step.rs +69 -18
- data/rust/host/src/rate_limit.rs +541 -142
- data/rust/host/src/reference_transform.rs +77 -25
- data/rust/host/src/reference_validate.rs +443 -104
- data/rust/host/src/resend.rs +190 -55
- data/rust/host/src/secrets.rs +116 -21
- data/rust/host/src/server.rs +555 -104
- data/rust/host/src/storage_shape.rs +88 -21
- data/rust/host/src/test_pg.rs +17 -7
- data/rust/host/src/ui_schema.rs +495 -207
- data/rust/host/src/wasm_runner.rs +21 -6
- data/rust/host/src/web/commerce.rs +101 -23
- data/rust/host/src/web/newsletter/cooldown.rs +10 -3
- data/rust/host/src/web/newsletter/template.rs +26 -7
- data/rust/host/src/web/newsletter/tests.rs +112 -23
- data/rust/host/src/web/newsletter.rs +209 -59
- data/rust/host/src/web/newsletter_send.rs +251 -79
- data/rust/host/src/web/registration_receipt/tests.rs +16 -4
- data/rust/host/src/web/registration_receipt.rs +100 -24
- data/rust/host/src/web/registrations.rs +1384 -332
- data/rust/host/src/web/resend_webhook.rs +138 -80
- data/rust/host/src/web.rs +2416 -508
- data/rust/lsp/Cargo.toml +36 -0
- data/rust/lsp/clippy.toml +4 -0
- data/rust/lsp/src/json.rs +29 -22
- data/rust/lsp/src/main.rs +81 -31
- data/rust/lsp/src/outline.rs +23 -7
- data/rust/lsp/src/rpc.rs +17 -4
- data/rust/parser/Cargo.toml +36 -0
- data/rust/parser/clippy.toml +4 -0
- data/rust/parser/src/build/closed_sets.rs +6 -1
- data/rust/parser/src/build/identity.rs +4 -3
- data/rust/parser/src/build/mod.rs +1 -1
- data/rust/parser/src/build/named_pattern.rs +4 -1
- data/rust/parser/src/build/naming.rs +2 -80
- data/rust/parser/src/build/pattern_subset.rs +6 -8
- data/rust/parser/src/build/query_derive.rs +3 -1
- data/rust/parser/src/build/query_inference.rs +14 -5
- data/rust/parser/src/build/read_model.rs +4 -1
- data/rust/parser/src/build/references.rs +4 -3
- data/rust/parser/src/canonical.rs +34 -15
- data/rust/parser/src/emit.rs +187 -105
- data/rust/parser/src/expr/ast_json.rs +134 -42
- data/rust/parser/src/expr/evaluator.rs +27 -9
- data/rust/parser/src/expr/mod.rs +54 -13
- data/rust/parser/src/expr/resolver.rs +299 -92
- data/rust/parser/src/ir.rs +35 -3
- data/rust/parser/src/keywords.rs +7168 -516
- data/rust/parser/src/lex.rs +11 -25
- data/rust/parser/src/lib.rs +1 -0
- data/rust/parser/src/main.rs +65 -6
- data/rust/parser/src/parse/adapter.rs +157 -0
- data/rust/parser/src/parse/aggregate.rs +19 -10
- data/rust/parser/src/parse/chapter.rs +16 -17
- data/rust/parser/src/parse/command.rs +35 -16
- data/rust/parser/src/parse/domain_port.rs +4 -7
- data/rust/parser/src/parse/entity.rs +21 -11
- data/rust/parser/src/parse/hecksagon.rs +10 -4
- data/rust/parser/src/parse/lifecycle.rs +9 -5
- data/rust/parser/src/parse/mod.rs +22 -91
- data/rust/parser/src/parse/needs.rs +21 -12
- data/rust/parser/src/parse/policy.rs +2 -2
- data/rust/parser/src/parse/process_manager.rs +9 -11
- data/rust/parser/src/parse/query.rs +8 -4
- data/rust/parser/src/parse/read_model.rs +57 -16
- data/rust/parser/src/parse/value_object.rs +7 -2
- data/rust/parser/src/parse/world.rs +322 -0
- data/rust/parser/src/ruby_value.rs +7 -6
- data/rust/src/exemplar/json.rs +4 -4
- data/rust/src/exemplar/mutations.rs +18 -0
- data/rust/src/exemplar/read_models.rs +1 -10
- data/rust/src/kernel/attribute_shapes/composite.rs +54 -14
- data/rust/src/kernel/attribute_shapes/list.rs +2 -2
- data/rust/src/kernel/attribute_shapes/mod.rs +8 -8
- data/rust/src/kernel/attribute_shapes/scalar.rs +2 -2
- data/rust/src/kernel/cli.rs +244 -114
- data/rust/src/kernel/dispatch.rs +323 -115
- data/rust/src/kernel/dispatch_guard_tests.rs +327 -65
- data/rust/src/kernel/expr.rs +203 -86
- data/rust/src/kernel/expression_operators/arithmetic.rs +138 -25
- data/rust/src/kernel/expression_operators/comparison.rs +87 -2
- data/rust/src/kernel/expression_operators/enumeration.rs +106 -22
- data/rust/src/kernel/expression_operators/logical.rs +9 -3
- data/rust/src/kernel/expression_operators/membership.rs +3 -1
- data/rust/src/kernel/expression_operators/mod.rs +34 -21
- data/rust/src/kernel/expression_operators/pattern_match.rs +46 -11
- data/rust/src/kernel/expression_operators/positional.rs +84 -30
- data/rust/src/kernel/expression_operators/presence.rs +52 -13
- data/rust/src/kernel/expression_operators/sign_test.rs +3 -1
- data/rust/src/kernel/expression_operators/sized.rs +24 -8
- data/rust/src/kernel/expression_operators/text.rs +120 -27
- data/rust/src/kernel/expression_operators/to_string.rs +10 -4
- data/rust/src/kernel/json.rs +244 -148
- data/rust/src/kernel/mod.rs +47 -33
- data/rust/src/kernel/named_query.rs +63 -15
- data/rust/src/kernel/needs.rs +112 -25
- data/rust/src/kernel/orchestrate.rs +980 -581
- data/rust/src/kernel/orchestrate_guard_tests.rs +538 -140
- data/rust/src/kernel/pattern.rs +107 -34
- data/rust/src/kernel/present.rs +261 -0
- data/rust/src/kernel/query_comparators.rs +118 -41
- data/rust/src/kernel/query_ordering.rs +13 -5
- data/rust/src/kernel/read_model.rs +479 -181
- data/rust/src/kernel/reference_lookup.rs +122 -26
- data/rust/src/kernel/repository.rs +293 -66
- data/rust/src/kernel/routing.rs +58 -42
- data/rust/src/kernel/ruby_float.rs +139 -0
- data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +57 -57
- data/rust/src/kernel/vocab/entity_dispatch_order.rs +54 -54
- data/rust/src/kernel/vocab/field_hint.rs +19 -25
- data/rust/src/kernel/vocab/mod.rs +6 -6
- data/rust/src/kernel/vocab/query_comparator.rs +23 -24
- data/rust/src/kernel/vocab/refusal_template.rs +501 -297
- data/rust/src/lib.rs +50 -0
- data/rust/src/main.rs +4 -1
- data/rust/web/Cargo.toml +36 -0
- data/rust/web/clippy.toml +4 -0
- metadata +85 -15
- data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +0 -36
- data/lib/hecks/adapters/driven/google_authentication.adapter +0 -3
- data/lib/hecks/adapters/driven/google_authentication.rb +0 -99
- data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +0 -3
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +0 -32
- data/lib/hecks/ports/authentication.port +0 -6
- data/lib/hecks/ports/authentication.rb +0 -67
- data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +0 -255
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +0 -22
- data/rust/host/src/approval.rs +0 -652
data/rust/host/src/dispatch.rs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
//! Ties the journal and the sandbox together: replays history, then the
|
|
2
2
|
//! new step, persisting it only if the module accepts it.
|
|
3
3
|
|
|
4
|
+
use crate::host::Host;
|
|
4
5
|
use crate::journal;
|
|
5
|
-
use crate::
|
|
6
|
-
use crate::lambda_client::{self, LambdaInvoker};
|
|
6
|
+
use crate::lambda_client::{self};
|
|
7
7
|
use crate::wasm_runner;
|
|
8
8
|
use std::path::Path;
|
|
9
9
|
use tokio::sync::Mutex;
|
|
@@ -16,7 +16,7 @@ pub struct Outcome {
|
|
|
16
16
|
|
|
17
17
|
/// Who a command is dispatched for: the role they state and, when the host knows who they are, the
|
|
18
18
|
/// actor Governance holds assignments for. Both absent is an unidentified caller.
|
|
19
|
-
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
|
19
|
+
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
20
20
|
pub struct Caller<'a> {
|
|
21
21
|
pub role: Option<&'a str>,
|
|
22
22
|
pub actor_id: Option<&'a str>,
|
|
@@ -27,7 +27,7 @@ pub struct Caller<'a> {
|
|
|
27
27
|
pub const ANONYMOUS_ROLE: &str = "Anonymous";
|
|
28
28
|
|
|
29
29
|
/// What the host does about the role a command declares (`HECKS_ROLE_ENFORCEMENT`).
|
|
30
|
-
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
30
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
31
31
|
pub enum Enforcement {
|
|
32
32
|
/// A caller with no role is unchecked, as before.
|
|
33
33
|
Off,
|
|
@@ -56,9 +56,12 @@ impl Enforcement {
|
|
|
56
56
|
|
|
57
57
|
/// The caller the kernel is asked to check: an unidentified caller becomes the anonymous role
|
|
58
58
|
/// unless enforcement is off.
|
|
59
|
-
pub fn effective
|
|
59
|
+
pub fn effective(self, caller: Caller<'_>) -> Caller<'_> {
|
|
60
60
|
if self != Self::Off && caller.role.is_none() && caller.actor_id.is_none() {
|
|
61
|
-
return Caller {
|
|
61
|
+
return Caller {
|
|
62
|
+
role: Some(ANONYMOUS_ROLE),
|
|
63
|
+
actor_id: None,
|
|
64
|
+
};
|
|
62
65
|
}
|
|
63
66
|
caller
|
|
64
67
|
}
|
|
@@ -75,9 +78,13 @@ enum Origin {
|
|
|
75
78
|
|
|
76
79
|
/// Whether the kernel refused the command for the caller's role.
|
|
77
80
|
fn refused_for_role(result: &serde_json::Value) -> bool {
|
|
78
|
-
result
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
result
|
|
82
|
+
.get("refusals")
|
|
83
|
+
.and_then(|r| r.as_array())
|
|
84
|
+
.is_some_and(|all| {
|
|
85
|
+
all.iter()
|
|
86
|
+
.any(|r| r.get("kind").and_then(|k| k.as_str()) == Some("Unauthorized"))
|
|
87
|
+
})
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
// Distinguishes a module refusal from a kernel failure to run at all.
|
|
@@ -92,9 +99,16 @@ enum KernelAnswer {
|
|
|
92
99
|
// refusals doesn't by itself mean the run succeeded.
|
|
93
100
|
fn classify(result: &serde_json::Value) -> KernelAnswer {
|
|
94
101
|
if let Some(error) = result.get("error") {
|
|
95
|
-
return KernelAnswer::Failed(
|
|
102
|
+
return KernelAnswer::Failed(
|
|
103
|
+
error
|
|
104
|
+
.as_str()
|
|
105
|
+
.map_or_else(|| error.to_string(), str::to_string),
|
|
106
|
+
);
|
|
96
107
|
}
|
|
97
|
-
let refused = result
|
|
108
|
+
let refused = result
|
|
109
|
+
.get("refusals")
|
|
110
|
+
.and_then(|r| r.as_array())
|
|
111
|
+
.is_some_and(|r| !r.is_empty());
|
|
98
112
|
if refused {
|
|
99
113
|
KernelAnswer::Refused
|
|
100
114
|
} else {
|
|
@@ -107,18 +121,26 @@ fn classify(result: &serde_json::Value) -> KernelAnswer {
|
|
|
107
121
|
fn parse_kernel_output(output: &str) -> anyhow::Result<serde_json::Value> {
|
|
108
122
|
let result: serde_json::Value = serde_json::from_str(output)?;
|
|
109
123
|
match classify(&result) {
|
|
110
|
-
KernelAnswer::Failed(message) =>
|
|
124
|
+
KernelAnswer::Failed(message) => {
|
|
125
|
+
anyhow::bail!("the kernel failed before it could run the command: {message}")
|
|
126
|
+
}
|
|
111
127
|
KernelAnswer::Accepted | KernelAnswer::Refused => Ok(result),
|
|
112
128
|
}
|
|
113
129
|
}
|
|
114
130
|
|
|
115
131
|
// Kept as one opaque object under the journal's `args` column; the kernel
|
|
116
132
|
// unwraps `to`/`with` itself, so this needs no journal schema migration.
|
|
117
|
-
pub fn routed_invocation(
|
|
133
|
+
pub fn routed_invocation(
|
|
134
|
+
to: serde_json::Value,
|
|
135
|
+
facts: serde_json::Value,
|
|
136
|
+
) -> anyhow::Result<serde_json::Value> {
|
|
118
137
|
if !facts.is_object() {
|
|
119
138
|
anyhow::bail!("with must be an object of command facts");
|
|
120
139
|
}
|
|
121
|
-
|
|
140
|
+
let mut invocation = serde_json::Map::new();
|
|
141
|
+
invocation.insert("to".to_string(), to);
|
|
142
|
+
invocation.insert("with".to_string(), facts);
|
|
143
|
+
Ok(serde_json::Value::Object(invocation))
|
|
122
144
|
}
|
|
123
145
|
|
|
124
146
|
// No `to` route: used only when a command creates a new aggregate, so
|
|
@@ -127,66 +149,52 @@ pub fn facts_invocation(facts: serde_json::Value) -> anyhow::Result<serde_json::
|
|
|
127
149
|
if !facts.is_object() {
|
|
128
150
|
anyhow::bail!("with must be an object of command facts");
|
|
129
151
|
}
|
|
130
|
-
|
|
152
|
+
let mut invocation = serde_json::Map::new();
|
|
153
|
+
invocation.insert("with".to_string(), facts);
|
|
154
|
+
Ok(serde_json::Value::Object(invocation))
|
|
131
155
|
}
|
|
132
156
|
|
|
133
157
|
// Route-aware entry point; `handle` below stays the compatibility path
|
|
134
158
|
// for callers still sending the older mixed args shape.
|
|
135
|
-
#[allow(clippy::too_many_arguments)]
|
|
136
159
|
pub async fn handle_routed(
|
|
137
|
-
|
|
138
|
-
wasm_path: &Path,
|
|
160
|
+
host: Host<'_>,
|
|
139
161
|
verb: &str,
|
|
140
162
|
to: serde_json::Value,
|
|
141
163
|
facts: serde_json::Value,
|
|
142
164
|
role: Option<&str>,
|
|
143
|
-
config: &LineageConfig,
|
|
144
|
-
invoker: &dyn LambdaInvoker,
|
|
145
165
|
) -> anyhow::Result<Outcome> {
|
|
146
|
-
handle(
|
|
166
|
+
handle(host, verb, routed_invocation(to, facts)?, role).await
|
|
147
167
|
}
|
|
148
168
|
|
|
149
169
|
/// `handle_routed` for a caller the host can name.
|
|
150
|
-
#[allow(clippy::too_many_arguments)]
|
|
151
170
|
pub async fn handle_routed_as(
|
|
152
|
-
|
|
153
|
-
wasm_path: &Path,
|
|
171
|
+
host: Host<'_>,
|
|
154
172
|
verb: &str,
|
|
155
173
|
to: serde_json::Value,
|
|
156
174
|
facts: serde_json::Value,
|
|
157
175
|
caller: Caller<'_>,
|
|
158
|
-
config: &LineageConfig,
|
|
159
|
-
invoker: &dyn LambdaInvoker,
|
|
160
176
|
) -> anyhow::Result<Outcome> {
|
|
161
|
-
handle_as(
|
|
177
|
+
handle_as(host, verb, routed_invocation(to, facts)?, caller).await
|
|
162
178
|
}
|
|
163
179
|
|
|
164
180
|
// Facts-only counterpart to `handle_routed`.
|
|
165
|
-
#[allow(clippy::too_many_arguments)]
|
|
166
181
|
pub async fn handle_facts(
|
|
167
|
-
|
|
168
|
-
wasm_path: &Path,
|
|
182
|
+
host: Host<'_>,
|
|
169
183
|
verb: &str,
|
|
170
184
|
facts: serde_json::Value,
|
|
171
185
|
role: Option<&str>,
|
|
172
|
-
config: &LineageConfig,
|
|
173
|
-
invoker: &dyn LambdaInvoker,
|
|
174
186
|
) -> anyhow::Result<Outcome> {
|
|
175
|
-
handle(
|
|
187
|
+
handle(host, verb, facts_invocation(facts)?, role).await
|
|
176
188
|
}
|
|
177
189
|
|
|
178
190
|
/// `handle_facts` for a caller the host can name.
|
|
179
|
-
#[allow(clippy::too_many_arguments)]
|
|
180
191
|
pub async fn handle_facts_as(
|
|
181
|
-
|
|
182
|
-
wasm_path: &Path,
|
|
192
|
+
host: Host<'_>,
|
|
183
193
|
verb: &str,
|
|
184
194
|
facts: serde_json::Value,
|
|
185
195
|
caller: Caller<'_>,
|
|
186
|
-
config: &LineageConfig,
|
|
187
|
-
invoker: &dyn LambdaInvoker,
|
|
188
196
|
) -> anyhow::Result<Outcome> {
|
|
189
|
-
handle_as(
|
|
197
|
+
handle_as(host, verb, facts_invocation(facts)?, caller).await
|
|
190
198
|
}
|
|
191
199
|
|
|
192
200
|
// A Mutex, not a bare Arc<Client>: `handle` needs `Client::transaction`,
|
|
@@ -205,68 +213,95 @@ pub async fn handle_facts_as(
|
|
|
205
213
|
// host-internal dispatch and is unchecked in every enforcement mode. A call that names a role is
|
|
206
214
|
// still checked against it. Callers outside the host go through the `_as` entry points.
|
|
207
215
|
pub async fn handle(
|
|
208
|
-
|
|
209
|
-
wasm_path: &Path,
|
|
216
|
+
host: Host<'_>,
|
|
210
217
|
verb: &str,
|
|
211
218
|
args: serde_json::Value,
|
|
212
219
|
role: Option<&str>,
|
|
213
|
-
config: &LineageConfig,
|
|
214
|
-
invoker: &dyn LambdaInvoker,
|
|
215
220
|
) -> anyhow::Result<Outcome> {
|
|
216
|
-
let origin = if role.is_none() {
|
|
217
|
-
|
|
221
|
+
let origin = if role.is_none() {
|
|
222
|
+
Origin::HostInternal
|
|
223
|
+
} else {
|
|
224
|
+
Origin::External
|
|
225
|
+
};
|
|
226
|
+
dispatch_in(
|
|
227
|
+
Enforcement::from_env(),
|
|
228
|
+
origin,
|
|
229
|
+
host,
|
|
230
|
+
verb,
|
|
231
|
+
args,
|
|
232
|
+
Caller {
|
|
233
|
+
role,
|
|
234
|
+
actor_id: None,
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
.await
|
|
218
238
|
}
|
|
219
239
|
|
|
220
240
|
/// Dispatches for a caller, applying `HECKS_ROLE_ENFORCEMENT`: unidentified callers are refused (or,
|
|
221
241
|
/// in shadow, logged as would-be refusals and let through).
|
|
222
242
|
pub async fn handle_as(
|
|
223
|
-
|
|
224
|
-
wasm_path: &Path,
|
|
243
|
+
host: Host<'_>,
|
|
225
244
|
verb: &str,
|
|
226
245
|
args: serde_json::Value,
|
|
227
246
|
caller: Caller<'_>,
|
|
228
|
-
config: &LineageConfig,
|
|
229
|
-
invoker: &dyn LambdaInvoker,
|
|
230
247
|
) -> anyhow::Result<Outcome> {
|
|
231
|
-
dispatch_in(
|
|
248
|
+
dispatch_in(
|
|
249
|
+
Enforcement::from_env(),
|
|
250
|
+
Origin::External,
|
|
251
|
+
host,
|
|
252
|
+
verb,
|
|
253
|
+
args,
|
|
254
|
+
caller,
|
|
255
|
+
)
|
|
256
|
+
.await
|
|
232
257
|
}
|
|
233
258
|
|
|
234
|
-
#[allow(clippy::too_many_arguments)]
|
|
235
259
|
async fn dispatch_in(
|
|
236
260
|
mode: Enforcement,
|
|
237
261
|
origin: Origin,
|
|
238
|
-
|
|
239
|
-
wasm_path: &Path,
|
|
262
|
+
host: Host<'_>,
|
|
240
263
|
verb: &str,
|
|
241
264
|
args: serde_json::Value,
|
|
242
265
|
caller: Caller<'_>,
|
|
243
|
-
config: &LineageConfig,
|
|
244
|
-
invoker: &dyn LambdaInvoker,
|
|
245
266
|
) -> anyhow::Result<Outcome> {
|
|
246
|
-
let checked = if origin == Origin::HostInternal {
|
|
247
|
-
|
|
267
|
+
let checked = if origin == Origin::HostInternal {
|
|
268
|
+
caller
|
|
269
|
+
} else {
|
|
270
|
+
mode.effective(caller)
|
|
271
|
+
};
|
|
272
|
+
let outcome = run(host, verb, args.clone(), checked).await?;
|
|
248
273
|
// Only a caller that stated no role (unidentified, or an actor Governance holds no grant for)
|
|
249
274
|
// is let through; a stated role that does not match stays refused, as it is under `Off`.
|
|
250
|
-
if mode == Enforcement::Shadow
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
275
|
+
if mode == Enforcement::Shadow
|
|
276
|
+
&& caller.role.is_none()
|
|
277
|
+
&& !outcome.accepted
|
|
278
|
+
&& refused_for_role(&outcome.result)
|
|
279
|
+
{
|
|
280
|
+
crate::log::error(
|
|
281
|
+
"would_refuse_role",
|
|
282
|
+
serde_json::json!({
|
|
283
|
+
"verb": verb, "role": checked.role, "actor_id": checked.actor_id,
|
|
284
|
+
"refusals": outcome.result.get("refusals"),
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
return run(host, verb, args, Caller::default()).await;
|
|
256
288
|
}
|
|
257
289
|
Ok(outcome)
|
|
258
290
|
}
|
|
259
291
|
|
|
260
|
-
#[allow(clippy::too_many_arguments)]
|
|
261
292
|
async fn run(
|
|
262
|
-
|
|
263
|
-
wasm_path: &Path,
|
|
293
|
+
host: Host<'_>,
|
|
264
294
|
verb: &str,
|
|
265
295
|
args: serde_json::Value,
|
|
266
296
|
caller: Caller<'_>,
|
|
267
|
-
config: &LineageConfig,
|
|
268
|
-
invoker: &dyn LambdaInvoker,
|
|
269
297
|
) -> anyhow::Result<Outcome> {
|
|
298
|
+
let Host {
|
|
299
|
+
client,
|
|
300
|
+
wasm_path,
|
|
301
|
+
config,
|
|
302
|
+
invoker,
|
|
303
|
+
..
|
|
304
|
+
} = host;
|
|
270
305
|
let role = caller.role;
|
|
271
306
|
let mut guard = client.lock().await;
|
|
272
307
|
let txn = guard.transaction().await?;
|
|
@@ -327,7 +362,9 @@ async fn run(
|
|
|
327
362
|
let seed = if needs_saga_backfill {
|
|
328
363
|
serde_json::json!({})
|
|
329
364
|
} else {
|
|
330
|
-
snapshot
|
|
365
|
+
snapshot
|
|
366
|
+
.as_ref()
|
|
367
|
+
.map_or_else(|| serde_json::json!({}), |s| s.seed.clone())
|
|
331
368
|
};
|
|
332
369
|
|
|
333
370
|
// Read inside the same advisory-locked transaction: the lock covers
|
|
@@ -378,7 +415,10 @@ async fn run(
|
|
|
378
415
|
|
|
379
416
|
// The kernel's "instances" output is already the exact seed
|
|
380
417
|
// shape, so nothing here re-derives or filters it.
|
|
381
|
-
let new_seed = result
|
|
418
|
+
let new_seed = result
|
|
419
|
+
.get("instances")
|
|
420
|
+
.cloned()
|
|
421
|
+
.unwrap_or_else(|| serde_json::json!({}));
|
|
382
422
|
journal::save_snapshot(&txn, ordinal, &new_seed).await?;
|
|
383
423
|
|
|
384
424
|
// Persisted in the same transaction, so a saga checkpoint can
|
|
@@ -386,24 +426,42 @@ async fn run(
|
|
|
386
426
|
// Reconciled against `saga_rows` (pre-run state): present now
|
|
387
427
|
// is upserted, present before but absent now is deleted.
|
|
388
428
|
if let Some(saga_snapshot) = result.get("saga_snapshot").and_then(|v| v.as_array()) {
|
|
389
|
-
let mut still_present: std::collections::HashSet<(String, String)> =
|
|
429
|
+
let mut still_present: std::collections::HashSet<(String, String)> =
|
|
430
|
+
std::collections::HashSet::new();
|
|
390
431
|
for entry in saga_snapshot {
|
|
391
432
|
let process_manager = entry
|
|
392
433
|
.get("process_manager")
|
|
393
434
|
.and_then(|v| v.as_str())
|
|
394
|
-
.ok_or_else(||
|
|
435
|
+
.ok_or_else(|| {
|
|
436
|
+
anyhow::anyhow!("saga_snapshot entry missing \"process_manager\": {entry}")
|
|
437
|
+
})?;
|
|
395
438
|
let correlation = entry
|
|
396
439
|
.get("correlation")
|
|
397
440
|
.and_then(|v| v.as_str())
|
|
398
|
-
.ok_or_else(||
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
let
|
|
405
|
-
|
|
406
|
-
|
|
441
|
+
.ok_or_else(|| {
|
|
442
|
+
anyhow::anyhow!("saga_snapshot entry missing \"correlation\": {entry}")
|
|
443
|
+
})?;
|
|
444
|
+
let state = entry.get("state").and_then(|v| v.as_str()).ok_or_else(|| {
|
|
445
|
+
anyhow::anyhow!("saga_snapshot entry missing \"state\": {entry}")
|
|
446
|
+
})?;
|
|
447
|
+
let memory = entry
|
|
448
|
+
.get("memory")
|
|
449
|
+
.cloned()
|
|
450
|
+
.unwrap_or_else(|| serde_json::json!({}));
|
|
451
|
+
let completed_compensations = entry
|
|
452
|
+
.get("completed_compensations")
|
|
453
|
+
.cloned()
|
|
454
|
+
.unwrap_or_else(|| serde_json::json!([]));
|
|
455
|
+
|
|
456
|
+
journal::save_saga(
|
|
457
|
+
&txn,
|
|
458
|
+
process_manager,
|
|
459
|
+
correlation,
|
|
460
|
+
state,
|
|
461
|
+
&memory,
|
|
462
|
+
&completed_compensations,
|
|
463
|
+
)
|
|
464
|
+
.await?;
|
|
407
465
|
still_present.insert((process_manager.to_string(), correlation.to_string()));
|
|
408
466
|
}
|
|
409
467
|
for row in &saga_rows {
|
|
@@ -434,7 +492,9 @@ async fn run(
|
|
|
434
492
|
let aggregate = mutation
|
|
435
493
|
.get("aggregate")
|
|
436
494
|
.and_then(|v| v.as_str())
|
|
437
|
-
.ok_or_else(||
|
|
495
|
+
.ok_or_else(|| {
|
|
496
|
+
anyhow::anyhow!("mutation record missing \"aggregate\": {mutation}")
|
|
497
|
+
})?;
|
|
438
498
|
// `era.is_some()` means the lineage subsystem exists, not that
|
|
439
499
|
// this aggregate has a mirror: `mint` only provisions head
|
|
440
500
|
// snapshots for the capable set, so mirroring anything else
|
|
@@ -449,7 +509,9 @@ async fn run(
|
|
|
449
509
|
let operation = mutation
|
|
450
510
|
.get("operation")
|
|
451
511
|
.and_then(|v| v.as_str())
|
|
452
|
-
.ok_or_else(||
|
|
512
|
+
.ok_or_else(|| {
|
|
513
|
+
anyhow::anyhow!("mutation record missing \"operation\": {mutation}")
|
|
514
|
+
})?;
|
|
453
515
|
let state = mutation
|
|
454
516
|
.get("state")
|
|
455
517
|
.ok_or_else(|| anyhow::anyhow!("mutation record missing \"state\": {mutation}"))?;
|
|
@@ -457,7 +519,12 @@ async fn run(
|
|
|
457
519
|
journal::append_lineage_mutation(
|
|
458
520
|
&txn,
|
|
459
521
|
config,
|
|
460
|
-
&journal::Mutation {
|
|
522
|
+
&journal::Mutation {
|
|
523
|
+
aggregate,
|
|
524
|
+
id,
|
|
525
|
+
operation,
|
|
526
|
+
state,
|
|
527
|
+
},
|
|
461
528
|
)
|
|
462
529
|
.await?;
|
|
463
530
|
}
|
|
@@ -495,7 +562,9 @@ async fn run(
|
|
|
495
562
|
match lambda_client::deliver_with_retry(invoker, reaction).await {
|
|
496
563
|
Ok(record) => {
|
|
497
564
|
if let Some(response) = result.as_object_mut() {
|
|
498
|
-
if let Some(reactions) =
|
|
565
|
+
if let Some(reactions) =
|
|
566
|
+
response.get_mut("reactions").and_then(|r| r.as_array_mut())
|
|
567
|
+
{
|
|
499
568
|
reactions.push(serde_json::json!({
|
|
500
569
|
"policy": record.policy, "on": reaction.get("on").and_then(|v| v.as_str()).unwrap_or_default(),
|
|
501
570
|
"trigger": record.target_verb, "delivered": record.delivered, "reason": record.reason,
|
|
@@ -506,10 +575,13 @@ async fn run(
|
|
|
506
575
|
}
|
|
507
576
|
Err(failure) => {
|
|
508
577
|
let error_text = format!("{:#}", failure.error);
|
|
509
|
-
crate::log::error(
|
|
510
|
-
"
|
|
511
|
-
|
|
512
|
-
|
|
578
|
+
crate::log::error(
|
|
579
|
+
"cross_domain_delivery_failed",
|
|
580
|
+
serde_json::json!({
|
|
581
|
+
"policy": failure.policy, "target_domain": failure.target_domain,
|
|
582
|
+
"target_verb": failure.target_verb, "attempts": failure.attempts, "error": error_text,
|
|
583
|
+
}),
|
|
584
|
+
);
|
|
513
585
|
journal::record_dead_letter(
|
|
514
586
|
&*guard,
|
|
515
587
|
&failure.policy,
|
|
@@ -530,7 +602,10 @@ async fn run(
|
|
|
530
602
|
return Err(error);
|
|
531
603
|
}
|
|
532
604
|
if let Some(response) = result.as_object_mut() {
|
|
533
|
-
response.insert(
|
|
605
|
+
response.insert(
|
|
606
|
+
"cross_domain_deliveries".to_string(),
|
|
607
|
+
serde_json::Value::Array(cross_domain_deliveries),
|
|
608
|
+
);
|
|
534
609
|
}
|
|
535
610
|
|
|
536
611
|
log_command(verb, role, accepted, &result);
|
|
@@ -548,11 +623,21 @@ fn log_command(verb: &str, role: Option<&str>, accepted: bool, result: &serde_js
|
|
|
548
623
|
}
|
|
549
624
|
}
|
|
550
625
|
|
|
551
|
-
fn command_fields(
|
|
626
|
+
fn command_fields(
|
|
627
|
+
verb: &str,
|
|
628
|
+
role: Option<&str>,
|
|
629
|
+
accepted: bool,
|
|
630
|
+
result: &serde_json::Value,
|
|
631
|
+
) -> serde_json::Value {
|
|
552
632
|
let events: Vec<serde_json::Value> = result
|
|
553
633
|
.get("events")
|
|
554
634
|
.and_then(|v| v.as_array())
|
|
555
|
-
.map(|items|
|
|
635
|
+
.map(|items| {
|
|
636
|
+
items
|
|
637
|
+
.iter()
|
|
638
|
+
.filter_map(|item| item.get("name").cloned())
|
|
639
|
+
.collect()
|
|
640
|
+
})
|
|
556
641
|
.unwrap_or_default();
|
|
557
642
|
serde_json::json!({
|
|
558
643
|
"verb": verb, "role": role, "accepted": accepted, "events": events,
|
|
@@ -586,7 +671,8 @@ pub async fn read(client: &Mutex<Client>, wasm_path: &Path) -> anyhow::Result<se
|
|
|
586
671
|
let input = serde_json::json!({ "seed": &s.seed, "steps": steps_since }).to_string();
|
|
587
672
|
let owned_wasm_path = wasm_path.to_path_buf();
|
|
588
673
|
let output =
|
|
589
|
-
tokio::task::spawn_blocking(move || wasm_runner::run(&owned_wasm_path, &input))
|
|
674
|
+
tokio::task::spawn_blocking(move || wasm_runner::run(&owned_wasm_path, &input))
|
|
675
|
+
.await??;
|
|
590
676
|
return parse_kernel_output(&output);
|
|
591
677
|
}
|
|
592
678
|
|
|
@@ -617,13 +703,41 @@ pub async fn query(
|
|
|
617
703
|
crate::needs::fill_query_needs(domain_ir, question, &mut args, &crate::needs::ProcessClock);
|
|
618
704
|
}
|
|
619
705
|
let state = read(client, wasm_path).await?;
|
|
620
|
-
let seed = state
|
|
621
|
-
|
|
706
|
+
let seed = state
|
|
707
|
+
.get("instances")
|
|
708
|
+
.cloned()
|
|
709
|
+
.unwrap_or_else(|| serde_json::json!({}));
|
|
710
|
+
let input = serde_json::json!({ "seed": seed, "steps": [{ "query": question, "args": args }] })
|
|
711
|
+
.to_string();
|
|
622
712
|
let owned_wasm_path = wasm_path.to_path_buf();
|
|
623
|
-
let output =
|
|
713
|
+
let output =
|
|
714
|
+
tokio::task::spawn_blocking(move || wasm_runner::run(&owned_wasm_path, &input)).await??;
|
|
624
715
|
parse_kernel_output(&output)
|
|
625
716
|
}
|
|
626
717
|
|
|
718
|
+
/// Answers `answer` (a kernel answer already computed and persisted) in the bare shape a caller
|
|
719
|
+
/// reads, by one more wasm run of the kernel's `{"present": answer}` input: each single-field
|
|
720
|
+
/// value object as its sole field. Nothing is stored or replayed.
|
|
721
|
+
pub async fn present(
|
|
722
|
+
wasm_path: &Path,
|
|
723
|
+
answer: serde_json::Value,
|
|
724
|
+
) -> anyhow::Result<serde_json::Value> {
|
|
725
|
+
let owned_wasm_path = wasm_path.to_path_buf();
|
|
726
|
+
tokio::task::spawn_blocking(move || {
|
|
727
|
+
present_with(&answer, |input| wasm_runner::run(&owned_wasm_path, input))
|
|
728
|
+
})
|
|
729
|
+
.await?
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/// Presents `answer` as `present` does, with `run` standing in for the kernel (text in, text out).
|
|
733
|
+
pub(crate) fn present_with(
|
|
734
|
+
answer: &serde_json::Value,
|
|
735
|
+
run: impl FnOnce(&str) -> anyhow::Result<String>,
|
|
736
|
+
) -> anyhow::Result<serde_json::Value> {
|
|
737
|
+
let input = serde_json::json!({ "present": answer }).to_string();
|
|
738
|
+
parse_kernel_output(&run(&input)?)
|
|
739
|
+
}
|
|
740
|
+
|
|
627
741
|
#[cfg(test)]
|
|
628
742
|
pub(crate) mod tests {
|
|
629
743
|
|
|
@@ -642,6 +756,8 @@ pub(crate) mod tests {
|
|
|
642
756
|
assert!(!fields.to_string().contains("a@b.c"));
|
|
643
757
|
}
|
|
644
758
|
use super::*;
|
|
759
|
+
use crate::journal::LineageConfig;
|
|
760
|
+
use crate::lambda_client::LambdaInvoker;
|
|
645
761
|
use tokio_postgres::NoTls;
|
|
646
762
|
|
|
647
763
|
// A throwaway Postgres database per test, uniquely named so
|
|
@@ -663,10 +779,9 @@ pub(crate) mod tests {
|
|
|
663
779
|
.await
|
|
664
780
|
.unwrap();
|
|
665
781
|
|
|
666
|
-
let (client, conn) =
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
.expect("connect to scratch db");
|
|
782
|
+
let (client, conn) = tokio_postgres::connect(&crate::test_pg::conninfo(name), NoTls)
|
|
783
|
+
.await
|
|
784
|
+
.expect("connect to scratch db");
|
|
670
785
|
tokio::spawn(async move {
|
|
671
786
|
let _ = conn.await;
|
|
672
787
|
});
|
|
@@ -677,11 +792,18 @@ pub(crate) mod tests {
|
|
|
677
792
|
// Not Ruby's real provisioning — just enough structure for
|
|
678
793
|
// `journal::current_era` and `append_lineage_mutation` to succeed,
|
|
679
794
|
// without reproducing Ruby's full DDL.
|
|
680
|
-
pub(crate) async fn provision_lineage(
|
|
795
|
+
pub(crate) async fn provision_lineage(
|
|
796
|
+
client: &Client,
|
|
797
|
+
domain: &str,
|
|
798
|
+
era: i32,
|
|
799
|
+
aggregate_storage_names: &[&str],
|
|
800
|
+
) {
|
|
681
801
|
// `int`, matching Ruby's real DDL: tokio_postgres requires the
|
|
682
802
|
// Rust and Postgres types to match, not just be compatible.
|
|
683
803
|
client
|
|
684
|
-
.batch_execute(
|
|
804
|
+
.batch_execute(
|
|
805
|
+
"CREATE TABLE IF NOT EXISTS hecks_eras (domain text, ordinal int, held_text text)",
|
|
806
|
+
)
|
|
685
807
|
.await
|
|
686
808
|
.unwrap();
|
|
687
809
|
client
|
|
@@ -710,7 +832,10 @@ pub(crate) mod tests {
|
|
|
710
832
|
for name in aggregate_storage_names {
|
|
711
833
|
// Domain-qualified (ADR 0059), matching what a real write
|
|
712
834
|
// targets.
|
|
713
|
-
let snapshot_table = journal::qualified_name(
|
|
835
|
+
let snapshot_table = journal::qualified_name(
|
|
836
|
+
domain,
|
|
837
|
+
&format!("{}_head_snapshot_{era}", journal::snake(name)),
|
|
838
|
+
);
|
|
714
839
|
client
|
|
715
840
|
.batch_execute(&format!(
|
|
716
841
|
"CREATE TABLE IF NOT EXISTS \"{snapshot_table}\" (id text PRIMARY KEY, ordinal bigint NOT NULL, \
|
|
@@ -748,15 +873,34 @@ pub(crate) mod tests {
|
|
|
748
873
|
|
|
749
874
|
// Mirroring everything is the outage: no head snapshot exists to
|
|
750
875
|
// upsert into.
|
|
751
|
-
let everything = LineageConfig {
|
|
752
|
-
|
|
876
|
+
let everything = LineageConfig {
|
|
877
|
+
domain: "Banking".to_string(),
|
|
878
|
+
era: Some(1),
|
|
879
|
+
mirrored: None,
|
|
880
|
+
};
|
|
881
|
+
let refused = handle_facts(
|
|
882
|
+
Host::new(&client, &wasm, &everything, &invoker),
|
|
883
|
+
"Banking::Customer.Register",
|
|
884
|
+
open.clone(),
|
|
885
|
+
None,
|
|
886
|
+
)
|
|
887
|
+
.await;
|
|
753
888
|
let message = match refused {
|
|
754
889
|
Ok(_) => panic!("expected the missing-relation failure"),
|
|
755
890
|
Err(e) => format!("{e:#}"),
|
|
756
891
|
};
|
|
757
|
-
assert!(
|
|
758
|
-
|
|
759
|
-
|
|
892
|
+
assert!(
|
|
893
|
+
message.contains("banking_customer_head_snapshot_1"),
|
|
894
|
+
"names the relation it could not write: {message}"
|
|
895
|
+
);
|
|
896
|
+
assert!(
|
|
897
|
+
message.contains("does not exist"),
|
|
898
|
+
"carries the database's own words: {message}"
|
|
899
|
+
);
|
|
900
|
+
assert!(
|
|
901
|
+
message.contains("Banking::Customer"),
|
|
902
|
+
"names the record: {message}"
|
|
903
|
+
);
|
|
760
904
|
|
|
761
905
|
// Mirroring what the IR actually declares — nothing — writes
|
|
762
906
|
// cleanly, into this crate's own journal.
|
|
@@ -765,17 +909,38 @@ pub(crate) mod tests {
|
|
|
765
909
|
era: Some(1),
|
|
766
910
|
mirrored: Some(std::collections::BTreeSet::new()),
|
|
767
911
|
};
|
|
768
|
-
let outcome = handle_facts(
|
|
769
|
-
|
|
770
|
-
.
|
|
912
|
+
let outcome = handle_facts(
|
|
913
|
+
Host::new(&client, &wasm, &declared, &invoker),
|
|
914
|
+
"Banking::Customer.Register",
|
|
915
|
+
open,
|
|
916
|
+
None,
|
|
917
|
+
)
|
|
918
|
+
.await
|
|
919
|
+
.expect("writes");
|
|
771
920
|
assert!(outcome.accepted, "{}", outcome.result);
|
|
772
921
|
|
|
773
922
|
let guard = client.lock().await;
|
|
774
|
-
let journalled: i64 = guard
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
923
|
+
let journalled: i64 = guard
|
|
924
|
+
.query_one("SELECT count(*) FROM hecks_lambda_journal", &[])
|
|
925
|
+
.await
|
|
926
|
+
.unwrap()
|
|
927
|
+
.get(0);
|
|
928
|
+
assert_eq!(
|
|
929
|
+
journalled, 1,
|
|
930
|
+
"durable in this crate's own journal, which is what `read` replays"
|
|
931
|
+
);
|
|
932
|
+
let mirror: Option<String> = guard
|
|
933
|
+
.query_one(
|
|
934
|
+
"SELECT to_regclass('banking_customer_head_snapshot_1')::text",
|
|
935
|
+
&[],
|
|
936
|
+
)
|
|
937
|
+
.await
|
|
938
|
+
.unwrap()
|
|
939
|
+
.get(0);
|
|
940
|
+
assert!(
|
|
941
|
+
mirror.is_none(),
|
|
942
|
+
"and no mirror was invented for an aggregate the IR never called capable"
|
|
943
|
+
);
|
|
779
944
|
}
|
|
780
945
|
|
|
781
946
|
// The other half: an aggregate the IR does call capable is still
|
|
@@ -787,29 +952,62 @@ pub(crate) mod tests {
|
|
|
787
952
|
let config = LineageConfig {
|
|
788
953
|
domain: "Banking".to_string(),
|
|
789
954
|
era: Some(1),
|
|
790
|
-
mirrored: Some(
|
|
955
|
+
mirrored: Some(std::iter::once("Banking::Customer".to_string()).collect()),
|
|
791
956
|
};
|
|
792
957
|
|
|
793
958
|
handle_facts(
|
|
794
|
-
|
|
795
|
-
|
|
959
|
+
Host::new(
|
|
960
|
+
&client,
|
|
961
|
+
&wasm_path(),
|
|
962
|
+
&config,
|
|
963
|
+
&crate::lambda_client::NeverInvoker,
|
|
964
|
+
),
|
|
796
965
|
"Banking::Customer.Register",
|
|
797
966
|
register("CUST-2"),
|
|
798
967
|
None,
|
|
799
|
-
&config,
|
|
800
|
-
&crate::lambda_client::NeverInvoker,
|
|
801
968
|
)
|
|
802
969
|
.await
|
|
803
970
|
.expect("writes");
|
|
804
971
|
|
|
805
972
|
let guard = client.lock().await;
|
|
806
|
-
let mirrored: i64 =
|
|
807
|
-
|
|
973
|
+
let mirrored: i64 = guard
|
|
974
|
+
.query_one("SELECT count(*) FROM banking_customer_head_snapshot_1", &[])
|
|
975
|
+
.await
|
|
976
|
+
.unwrap()
|
|
977
|
+
.get(0);
|
|
808
978
|
assert_eq!(mirrored, 1);
|
|
809
979
|
}
|
|
810
980
|
|
|
811
981
|
fn test_config(domain: &str, era: i32) -> LineageConfig {
|
|
812
|
-
LineageConfig {
|
|
982
|
+
LineageConfig {
|
|
983
|
+
domain: domain.to_string(),
|
|
984
|
+
era: Some(era),
|
|
985
|
+
mirrored: None,
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
#[test]
|
|
990
|
+
fn present_hands_the_kernel_the_answer_under_present_and_answers_what_it_returns() {
|
|
991
|
+
let answer = serde_json::json!({"instances": {"Shop::Item#a": {"name": {"value": "x"}}}});
|
|
992
|
+
let mut sent = String::new();
|
|
993
|
+
let presented = present_with(&answer, |input| {
|
|
994
|
+
sent = input.to_string();
|
|
995
|
+
Ok(r#"{"instances":{"Shop::Item#a":{"name":"x"}}}"#.to_string())
|
|
996
|
+
})
|
|
997
|
+
.unwrap();
|
|
998
|
+
assert_eq!(
|
|
999
|
+
serde_json::from_str::<serde_json::Value>(&sent).unwrap(),
|
|
1000
|
+
serde_json::json!({ "present": answer })
|
|
1001
|
+
);
|
|
1002
|
+
assert_eq!(presented["instances"]["Shop::Item#a"]["name"], "x");
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
#[test]
|
|
1006
|
+
fn present_is_an_error_when_the_kernel_fails() {
|
|
1007
|
+
let failed = present_with(&serde_json::json!({}), |_| {
|
|
1008
|
+
Ok(r#"{"error":"expected a top-level {\"steps\": [...]} object"}"#.to_string())
|
|
1009
|
+
});
|
|
1010
|
+
assert!(failed.is_err());
|
|
813
1011
|
}
|
|
814
1012
|
|
|
815
1013
|
pub(crate) fn wasm_path() -> std::path::PathBuf {
|
|
@@ -832,28 +1030,40 @@ pub(crate) mod tests {
|
|
|
832
1030
|
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
833
1031
|
let config = test_config("Banking", 1);
|
|
834
1032
|
|
|
835
|
-
handle(
|
|
836
|
-
|
|
837
|
-
.
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1033
|
+
handle(
|
|
1034
|
+
Host::new(&client, &wasm_path(), &config, &lambda_client::NeverInvoker),
|
|
1035
|
+
"Banking::Customer.Register",
|
|
1036
|
+
register("CUST-0100"),
|
|
1037
|
+
None,
|
|
1038
|
+
)
|
|
1039
|
+
.await
|
|
1040
|
+
.unwrap()
|
|
1041
|
+
.accepted
|
|
1042
|
+
.then_some(())
|
|
1043
|
+
.expect("registering a customer should succeed");
|
|
841
1044
|
|
|
842
1045
|
// `Customer.Suspended` is `where status == "suspended"` — a
|
|
843
1046
|
// freshly registered customer is active, so the query has to
|
|
844
1047
|
// answer with a real, empty row set, not everything.
|
|
845
|
-
let before = query(
|
|
1048
|
+
let before = query(
|
|
1049
|
+
&client,
|
|
1050
|
+
&wasm_path(),
|
|
1051
|
+
"Banking::Customer.Suspended",
|
|
1052
|
+
serde_json::json!({}),
|
|
1053
|
+
)
|
|
1054
|
+
.await
|
|
1055
|
+
.unwrap();
|
|
846
1056
|
assert_eq!(before["queries"][0]["query"], "Banking::Customer.Suspended");
|
|
847
|
-
assert_eq!(
|
|
1057
|
+
assert_eq!(
|
|
1058
|
+
before["queries"][0]["rows"].as_array().expect("rows").len(),
|
|
1059
|
+
0
|
|
1060
|
+
);
|
|
848
1061
|
|
|
849
1062
|
handle(
|
|
850
|
-
&client,
|
|
851
|
-
&wasm_path(),
|
|
1063
|
+
Host::new(&client, &wasm_path(), &config, &lambda_client::NeverInvoker),
|
|
852
1064
|
"Banking::Customer.Suspend",
|
|
853
1065
|
serde_json::json!({ "reference": "CUST-0100", "standing": { "value": "watch" } }),
|
|
854
1066
|
None,
|
|
855
|
-
&config,
|
|
856
|
-
&lambda_client::NeverInvoker,
|
|
857
1067
|
)
|
|
858
1068
|
.await
|
|
859
1069
|
.unwrap()
|
|
@@ -861,9 +1071,20 @@ pub(crate) mod tests {
|
|
|
861
1071
|
.then_some(())
|
|
862
1072
|
.expect("suspending a customer should succeed");
|
|
863
1073
|
|
|
864
|
-
let after = query(
|
|
1074
|
+
let after = query(
|
|
1075
|
+
&client,
|
|
1076
|
+
&wasm_path(),
|
|
1077
|
+
"Banking::Customer.Suspended",
|
|
1078
|
+
serde_json::json!({}),
|
|
1079
|
+
)
|
|
1080
|
+
.await
|
|
1081
|
+
.unwrap();
|
|
865
1082
|
let rows = after["queries"][0]["rows"].as_array().expect("rows");
|
|
866
|
-
assert_eq!(
|
|
1083
|
+
assert_eq!(
|
|
1084
|
+
rows.len(),
|
|
1085
|
+
1,
|
|
1086
|
+
"the suspended customer should be the one row: {after}"
|
|
1087
|
+
);
|
|
867
1088
|
assert_eq!(rows[0]["id"], "CUST-0100");
|
|
868
1089
|
}
|
|
869
1090
|
|
|
@@ -873,13 +1094,15 @@ pub(crate) mod tests {
|
|
|
873
1094
|
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
874
1095
|
|
|
875
1096
|
let outcome = handle(
|
|
876
|
-
|
|
877
|
-
|
|
1097
|
+
Host::new(
|
|
1098
|
+
&client,
|
|
1099
|
+
&wasm_path(),
|
|
1100
|
+
&test_config("Banking", 1),
|
|
1101
|
+
&lambda_client::NeverInvoker,
|
|
1102
|
+
),
|
|
878
1103
|
"Banking::Customer.Register",
|
|
879
1104
|
register("CUST-0001"),
|
|
880
1105
|
None,
|
|
881
|
-
&test_config("Banking", 1),
|
|
882
|
-
&lambda_client::NeverInvoker,
|
|
883
1106
|
)
|
|
884
1107
|
.await
|
|
885
1108
|
.unwrap();
|
|
@@ -893,7 +1116,10 @@ pub(crate) mod tests {
|
|
|
893
1116
|
// The lineage write itself: a row landed in the era-tagged,
|
|
894
1117
|
// storage-name-keyed journal shape, not just the flat log above.
|
|
895
1118
|
let journal_rows = guard
|
|
896
|
-
.query(
|
|
1119
|
+
.query(
|
|
1120
|
+
"SELECT era, aggregate, aggregate_id, operation FROM hecks_journal_banking",
|
|
1121
|
+
&[],
|
|
1122
|
+
)
|
|
897
1123
|
.await
|
|
898
1124
|
.unwrap();
|
|
899
1125
|
assert_eq!(journal_rows.len(), 1);
|
|
@@ -902,14 +1128,26 @@ pub(crate) mod tests {
|
|
|
902
1128
|
let aggregate: String = row.get(1);
|
|
903
1129
|
let aggregate_id: String = row.get(2);
|
|
904
1130
|
let operation: String = row.get(3);
|
|
905
|
-
assert_eq!(
|
|
1131
|
+
assert_eq!(
|
|
1132
|
+
(
|
|
1133
|
+
era,
|
|
1134
|
+
aggregate.as_str(),
|
|
1135
|
+
aggregate_id.as_str(),
|
|
1136
|
+
operation.as_str()
|
|
1137
|
+
),
|
|
1138
|
+
(1, "customer", "CUST-0001", "save")
|
|
1139
|
+
);
|
|
906
1140
|
|
|
907
1141
|
// domain-qualified (docs/decisions/0059) — snake("Banking") == "banking".
|
|
908
1142
|
let snapshot_rows = guard
|
|
909
1143
|
.query("SELECT id FROM banking_customer_head_snapshot_1", &[])
|
|
910
1144
|
.await
|
|
911
1145
|
.unwrap();
|
|
912
|
-
assert_eq!(
|
|
1146
|
+
assert_eq!(
|
|
1147
|
+
snapshot_rows.len(),
|
|
1148
|
+
1,
|
|
1149
|
+
"the head-snapshot table should carry exactly the one live record"
|
|
1150
|
+
);
|
|
913
1151
|
let id: String = snapshot_rows[0].get(0);
|
|
914
1152
|
assert_eq!(id, "CUST-0001");
|
|
915
1153
|
}
|
|
@@ -924,13 +1162,15 @@ pub(crate) mod tests {
|
|
|
924
1162
|
|
|
925
1163
|
let before = crate::auth::httpdate_now();
|
|
926
1164
|
let outcome = handle(
|
|
927
|
-
|
|
928
|
-
|
|
1165
|
+
Host::new(
|
|
1166
|
+
&client,
|
|
1167
|
+
&wasm_path(),
|
|
1168
|
+
&test_config("Banking", 1),
|
|
1169
|
+
&lambda_client::NeverInvoker,
|
|
1170
|
+
),
|
|
929
1171
|
"Banking::Customer.Register",
|
|
930
1172
|
register("CUST-OCCURRED-AT"),
|
|
931
1173
|
None,
|
|
932
|
-
&test_config("Banking", 1),
|
|
933
|
-
&lambda_client::NeverInvoker,
|
|
934
1174
|
)
|
|
935
1175
|
.await
|
|
936
1176
|
.unwrap();
|
|
@@ -939,11 +1179,16 @@ pub(crate) mod tests {
|
|
|
939
1179
|
assert!(outcome.accepted, "{:?}", outcome.result["refusals"]);
|
|
940
1180
|
let events = outcome.result["events"].as_array().unwrap();
|
|
941
1181
|
assert_eq!(events.len(), 1);
|
|
942
|
-
let occurred_at = events[0]["occurred_at"]
|
|
1182
|
+
let occurred_at = events[0]["occurred_at"]
|
|
1183
|
+
.as_str()
|
|
1184
|
+
.expect("occurred_at should be a real string, not null or absent");
|
|
943
1185
|
// Lexical comparison is valid: this format's digit-then-letter
|
|
944
1186
|
// layout sorts identically to chronological order for stamps
|
|
945
1187
|
// less than 10,000 years apart.
|
|
946
|
-
assert!(
|
|
1188
|
+
assert!(
|
|
1189
|
+
occurred_at >= before.as_str() && occurred_at <= after.as_str(),
|
|
1190
|
+
"{occurred_at} should fall between {before} and {after}"
|
|
1191
|
+
);
|
|
947
1192
|
}
|
|
948
1193
|
|
|
949
1194
|
#[tokio::test]
|
|
@@ -953,13 +1198,10 @@ pub(crate) mod tests {
|
|
|
953
1198
|
let config = test_config("Banking", 1);
|
|
954
1199
|
|
|
955
1200
|
let first = handle(
|
|
956
|
-
&client,
|
|
957
|
-
&wasm_path(),
|
|
1201
|
+
Host::new(&client, &wasm_path(), &config, &lambda_client::NeverInvoker),
|
|
958
1202
|
"Banking::Customer.Register",
|
|
959
1203
|
register("CUST-0001"),
|
|
960
1204
|
None,
|
|
961
|
-
&config,
|
|
962
|
-
&lambda_client::NeverInvoker,
|
|
963
1205
|
)
|
|
964
1206
|
.await
|
|
965
1207
|
.unwrap();
|
|
@@ -973,13 +1215,10 @@ pub(crate) mod tests {
|
|
|
973
1215
|
// succeeded independently: the second registration only refuses
|
|
974
1216
|
// if the first's effect was genuinely rebuilt from Postgres.
|
|
975
1217
|
let second = handle(
|
|
976
|
-
&client,
|
|
977
|
-
&wasm_path(),
|
|
1218
|
+
Host::new(&client, &wasm_path(), &config, &lambda_client::NeverInvoker),
|
|
978
1219
|
"Banking::Customer.Register",
|
|
979
1220
|
register("CUST-0001"),
|
|
980
1221
|
None,
|
|
981
|
-
&config,
|
|
982
|
-
&lambda_client::NeverInvoker,
|
|
983
1222
|
)
|
|
984
1223
|
.await
|
|
985
1224
|
.unwrap();
|
|
@@ -1007,17 +1246,28 @@ pub(crate) mod tests {
|
|
|
1007
1246
|
let config = test_config("Banking", 1);
|
|
1008
1247
|
|
|
1009
1248
|
for reference in ["CUST-0004", "CUST-0005", "CUST-0006"] {
|
|
1010
|
-
let outcome = handle(
|
|
1011
|
-
|
|
1012
|
-
.
|
|
1013
|
-
|
|
1249
|
+
let outcome = handle(
|
|
1250
|
+
Host::new(&client, &wasm_path(), &config, &lambda_client::NeverInvoker),
|
|
1251
|
+
"Banking::Customer.Register",
|
|
1252
|
+
register(reference),
|
|
1253
|
+
None,
|
|
1254
|
+
)
|
|
1255
|
+
.await
|
|
1256
|
+
.unwrap();
|
|
1257
|
+
assert!(
|
|
1258
|
+
outcome.accepted,
|
|
1259
|
+
"registering {reference} should succeed: {:?}",
|
|
1260
|
+
outcome.result
|
|
1261
|
+
);
|
|
1014
1262
|
|
|
1015
1263
|
let guard = client.lock().await;
|
|
1016
1264
|
let snapshot = journal::load_snapshot(&*guard)
|
|
1017
1265
|
.await
|
|
1018
1266
|
.unwrap()
|
|
1019
1267
|
.expect("a snapshot should exist after the first accepted command");
|
|
1020
|
-
let tail = journal::load_steps_after(&*guard, snapshot.ordinal)
|
|
1268
|
+
let tail = journal::load_steps_after(&*guard, snapshot.ordinal)
|
|
1269
|
+
.await
|
|
1270
|
+
.unwrap();
|
|
1021
1271
|
assert!(tail.is_empty(), "the snapshot should already reflect every accepted command, leaving nothing to replay");
|
|
1022
1272
|
|
|
1023
1273
|
// And the seed is right, not just non-empty: it should carry
|
|
@@ -1029,7 +1279,10 @@ pub(crate) mod tests {
|
|
|
1029
1279
|
);
|
|
1030
1280
|
}
|
|
1031
1281
|
|
|
1032
|
-
let final_snapshot = journal::load_snapshot(&*client.lock().await)
|
|
1282
|
+
let final_snapshot = journal::load_snapshot(&*client.lock().await)
|
|
1283
|
+
.await
|
|
1284
|
+
.unwrap()
|
|
1285
|
+
.unwrap();
|
|
1033
1286
|
let final_seed = final_snapshot.seed.as_object().unwrap();
|
|
1034
1287
|
assert_eq!(final_seed.len(), 3, "the snapshot should accumulate all three registrations, not just the latest: {final_seed:?}");
|
|
1035
1288
|
}
|
|
@@ -1037,8 +1290,17 @@ pub(crate) mod tests {
|
|
|
1037
1290
|
#[test]
|
|
1038
1291
|
fn a_top_level_error_is_a_kernel_failure_and_nothing_else_is() {
|
|
1039
1292
|
let failed = serde_json::json!({"error": "invalid seed: Registration.status: missing from JSON args"});
|
|
1040
|
-
assert_eq!(
|
|
1041
|
-
|
|
1293
|
+
assert_eq!(
|
|
1294
|
+
classify(&failed),
|
|
1295
|
+
KernelAnswer::Failed(
|
|
1296
|
+
"invalid seed: Registration.status: missing from JSON args".to_string()
|
|
1297
|
+
)
|
|
1298
|
+
);
|
|
1299
|
+
assert_eq!(
|
|
1300
|
+
classify(&serde_json::json!({"error": {"code": 7}})),
|
|
1301
|
+
KernelAnswer::Failed("{\"code\":7}".to_string()),
|
|
1302
|
+
"a non-text error is still a failure"
|
|
1303
|
+
);
|
|
1042
1304
|
|
|
1043
1305
|
let accepted = serde_json::json!({"instances": {}, "events": [], "refusals": []});
|
|
1044
1306
|
assert_eq!(classify(&accepted), KernelAnswer::Accepted);
|
|
@@ -1058,9 +1320,18 @@ pub(crate) mod tests {
|
|
|
1058
1320
|
|
|
1059
1321
|
#[test]
|
|
1060
1322
|
fn parse_kernel_output_turns_a_failure_into_an_error_and_keeps_refusals_as_results() {
|
|
1061
|
-
let error = parse_kernel_output(
|
|
1062
|
-
|
|
1063
|
-
|
|
1323
|
+
let error = parse_kernel_output(
|
|
1324
|
+
r#"{"error":"invalid seed: Customer.status: missing from JSON args"}"#,
|
|
1325
|
+
)
|
|
1326
|
+
.unwrap_err();
|
|
1327
|
+
assert!(
|
|
1328
|
+
format!("{error:#}").contains("invalid seed: Customer.status: missing from JSON args"),
|
|
1329
|
+
"{error:#}"
|
|
1330
|
+
);
|
|
1331
|
+
let refused = parse_kernel_output(
|
|
1332
|
+
r#"{"instances":{},"refusals":[{"verb":"v","error":"no","kind":"K"}]}"#,
|
|
1333
|
+
)
|
|
1334
|
+
.unwrap();
|
|
1064
1335
|
assert_eq!(classify(&refused), KernelAnswer::Refused);
|
|
1065
1336
|
assert!(parse_kernel_output("not json").is_err());
|
|
1066
1337
|
}
|
|
@@ -1075,55 +1346,153 @@ pub(crate) mod tests {
|
|
|
1075
1346
|
let config = test_config("Banking", 1);
|
|
1076
1347
|
let noinvoke = lambda_client::NeverInvoker;
|
|
1077
1348
|
|
|
1078
|
-
let first = handle(
|
|
1349
|
+
let first = handle(
|
|
1350
|
+
Host::new(&client, &wasm_path(), &config, &noinvoke),
|
|
1351
|
+
"Banking::Customer.Register",
|
|
1352
|
+
register("CUST-0001"),
|
|
1353
|
+
None,
|
|
1354
|
+
)
|
|
1355
|
+
.await
|
|
1356
|
+
.unwrap();
|
|
1079
1357
|
assert!(first.accepted, "{:?}", first.result);
|
|
1080
1358
|
|
|
1081
|
-
let whole_world = journal::load_snapshot(&*client.lock().await)
|
|
1359
|
+
let whole_world = journal::load_snapshot(&*client.lock().await)
|
|
1360
|
+
.await
|
|
1361
|
+
.unwrap()
|
|
1362
|
+
.unwrap()
|
|
1363
|
+
.seed;
|
|
1082
1364
|
let mut damaged = whole_world.clone();
|
|
1083
|
-
let customer = damaged
|
|
1084
|
-
|
|
1085
|
-
|
|
1365
|
+
let customer = damaged
|
|
1366
|
+
.as_object_mut()
|
|
1367
|
+
.unwrap()
|
|
1368
|
+
.values_mut()
|
|
1369
|
+
.next()
|
|
1370
|
+
.unwrap()
|
|
1371
|
+
.as_object_mut()
|
|
1372
|
+
.unwrap();
|
|
1373
|
+
assert!(
|
|
1374
|
+
customer.remove("status").is_some(),
|
|
1375
|
+
"the fixture Customer carries a status"
|
|
1376
|
+
);
|
|
1377
|
+
client
|
|
1378
|
+
.lock()
|
|
1379
|
+
.await
|
|
1380
|
+
.execute("UPDATE hecks_lambda_snapshot SET seed = $1", &[&damaged])
|
|
1381
|
+
.await
|
|
1382
|
+
.unwrap();
|
|
1086
1383
|
|
|
1087
1384
|
let before = snapshot_and_journal(&client).await;
|
|
1088
|
-
let failure = handle(
|
|
1089
|
-
|
|
1090
|
-
|
|
1385
|
+
let failure = handle(
|
|
1386
|
+
Host::new(&client, &wasm_path(), &config, &noinvoke),
|
|
1387
|
+
"Banking::Customer.Register",
|
|
1388
|
+
register("CUST-0002"),
|
|
1389
|
+
None,
|
|
1390
|
+
)
|
|
1391
|
+
.await;
|
|
1392
|
+
let message = format!(
|
|
1393
|
+
"{:#}",
|
|
1394
|
+
failure
|
|
1395
|
+
.err()
|
|
1396
|
+
.expect("a kernel failure must be an error, not an accepted outcome")
|
|
1397
|
+
);
|
|
1398
|
+
assert!(
|
|
1399
|
+
message.contains("invalid seed") && message.contains("status"),
|
|
1400
|
+
"{message}"
|
|
1401
|
+
);
|
|
1091
1402
|
|
|
1092
|
-
assert_eq!(
|
|
1403
|
+
assert_eq!(
|
|
1404
|
+
snapshot_and_journal(&client).await,
|
|
1405
|
+
before,
|
|
1406
|
+
"the failed command left the journal and the snapshot byte-identical"
|
|
1407
|
+
);
|
|
1093
1408
|
assert_eq!(before.2, 1, "and only the first command was ever journaled");
|
|
1094
|
-
let lineage_rows: i64 = client
|
|
1095
|
-
|
|
1409
|
+
let lineage_rows: i64 = client
|
|
1410
|
+
.lock()
|
|
1411
|
+
.await
|
|
1412
|
+
.query_one("SELECT count(*) FROM hecks_journal_banking", &[])
|
|
1413
|
+
.await
|
|
1414
|
+
.unwrap()
|
|
1415
|
+
.get(0);
|
|
1416
|
+
assert_eq!(
|
|
1417
|
+
lineage_rows, 1,
|
|
1418
|
+
"nothing was mirrored into the era journal either"
|
|
1419
|
+
);
|
|
1096
1420
|
|
|
1097
1421
|
// A read that must run the kernel over the damaged snapshot fails
|
|
1098
1422
|
// the same way, instead of answering an empty world.
|
|
1099
1423
|
client
|
|
1100
1424
|
.lock()
|
|
1101
1425
|
.await
|
|
1102
|
-
.execute(
|
|
1426
|
+
.execute(
|
|
1427
|
+
"INSERT INTO hecks_lambda_journal (verb, args) VALUES ($1, $2)",
|
|
1428
|
+
&[&"Banking::Customer.Register", ®ister("CUST-0009")],
|
|
1429
|
+
)
|
|
1103
1430
|
.await
|
|
1104
1431
|
.unwrap();
|
|
1105
|
-
let read_failure = read(&client, &wasm_path())
|
|
1106
|
-
|
|
1107
|
-
|
|
1432
|
+
let read_failure = read(&client, &wasm_path())
|
|
1433
|
+
.await
|
|
1434
|
+
.expect_err("a kernel failure is not an empty read");
|
|
1435
|
+
assert!(
|
|
1436
|
+
format!("{read_failure:#}").contains("invalid seed"),
|
|
1437
|
+
"{read_failure:#}"
|
|
1438
|
+
);
|
|
1439
|
+
let query_failure = query(
|
|
1440
|
+
&client,
|
|
1441
|
+
&wasm_path(),
|
|
1442
|
+
"Banking::Customer.Suspended",
|
|
1443
|
+
serde_json::json!({}),
|
|
1444
|
+
)
|
|
1445
|
+
.await
|
|
1446
|
+
.err();
|
|
1108
1447
|
assert!(query_failure.is_some_and(|e| format!("{e:#}").contains("invalid seed")));
|
|
1109
1448
|
client.lock().await.execute("DELETE FROM hecks_lambda_journal WHERE verb = 'Banking::Customer.Register' AND ordinal > 1", &[]).await.unwrap();
|
|
1110
1449
|
|
|
1111
1450
|
// Once the snapshot is whole again the next command works, and the
|
|
1112
1451
|
// snapshot afterwards holds the whole world, not just its own instance.
|
|
1113
|
-
client
|
|
1114
|
-
|
|
1452
|
+
client
|
|
1453
|
+
.lock()
|
|
1454
|
+
.await
|
|
1455
|
+
.execute(
|
|
1456
|
+
"UPDATE hecks_lambda_snapshot SET seed = $1",
|
|
1457
|
+
&[&whole_world],
|
|
1458
|
+
)
|
|
1459
|
+
.await
|
|
1460
|
+
.unwrap();
|
|
1461
|
+
let second = handle(
|
|
1462
|
+
Host::new(&client, &wasm_path(), &config, &noinvoke),
|
|
1463
|
+
"Banking::Customer.Register",
|
|
1464
|
+
register("CUST-0002"),
|
|
1465
|
+
None,
|
|
1466
|
+
)
|
|
1467
|
+
.await
|
|
1468
|
+
.unwrap();
|
|
1115
1469
|
assert!(second.accepted, "{:?}", second.result);
|
|
1116
|
-
let after = journal::load_snapshot(&*client.lock().await)
|
|
1470
|
+
let after = journal::load_snapshot(&*client.lock().await)
|
|
1471
|
+
.await
|
|
1472
|
+
.unwrap()
|
|
1473
|
+
.unwrap()
|
|
1474
|
+
.seed;
|
|
1117
1475
|
let keys: Vec<&String> = after.as_object().unwrap().keys().collect();
|
|
1118
1476
|
assert_eq!(keys.len(), 2, "the snapshot holds both customers: {keys:?}");
|
|
1119
|
-
assert!(
|
|
1477
|
+
assert!(
|
|
1478
|
+
keys.iter().any(|k| k.contains("CUST-0001"))
|
|
1479
|
+
&& keys.iter().any(|k| k.contains("CUST-0002")),
|
|
1480
|
+
"{keys:?}"
|
|
1481
|
+
);
|
|
1120
1482
|
}
|
|
1121
1483
|
|
|
1122
1484
|
// The snapshot's ordinal and exact seed text, and how many commands are journaled.
|
|
1123
1485
|
async fn snapshot_and_journal(client: &Mutex<Client>) -> (i64, String, i64) {
|
|
1124
1486
|
let guard = client.lock().await;
|
|
1125
|
-
let snapshot = guard
|
|
1126
|
-
|
|
1487
|
+
let snapshot = guard
|
|
1488
|
+
.query_one("SELECT ordinal, seed::text FROM hecks_lambda_snapshot", &[])
|
|
1489
|
+
.await
|
|
1490
|
+
.unwrap();
|
|
1491
|
+
let journaled: i64 = guard
|
|
1492
|
+
.query_one("SELECT count(*) FROM hecks_lambda_journal", &[])
|
|
1493
|
+
.await
|
|
1494
|
+
.unwrap()
|
|
1495
|
+
.get(0);
|
|
1127
1496
|
(snapshot.get(0), snapshot.get(1), journaled)
|
|
1128
1497
|
}
|
|
1129
1498
|
|
|
@@ -1141,8 +1510,18 @@ pub(crate) mod tests {
|
|
|
1141
1510
|
let wasm_path = wasm_path();
|
|
1142
1511
|
|
|
1143
1512
|
let (first, second) = tokio::join!(
|
|
1144
|
-
handle(
|
|
1145
|
-
|
|
1513
|
+
handle(
|
|
1514
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
1515
|
+
"Banking::Customer.Register",
|
|
1516
|
+
register("CUST-0002"),
|
|
1517
|
+
None
|
|
1518
|
+
),
|
|
1519
|
+
handle(
|
|
1520
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
1521
|
+
"Banking::Customer.Register",
|
|
1522
|
+
register("CUST-0002"),
|
|
1523
|
+
None
|
|
1524
|
+
),
|
|
1146
1525
|
);
|
|
1147
1526
|
let (first, second) = (first.unwrap(), second.unwrap());
|
|
1148
1527
|
|
|
@@ -1152,7 +1531,11 @@ pub(crate) mod tests {
|
|
|
1152
1531
|
first.result, second.result
|
|
1153
1532
|
);
|
|
1154
1533
|
let steps = journal::load_steps(&*client.lock().await).await.unwrap();
|
|
1155
|
-
assert_eq!(
|
|
1534
|
+
assert_eq!(
|
|
1535
|
+
steps.len(),
|
|
1536
|
+
1,
|
|
1537
|
+
"only the accepted registration should be persisted"
|
|
1538
|
+
);
|
|
1156
1539
|
}
|
|
1157
1540
|
|
|
1158
1541
|
#[tokio::test]
|
|
@@ -1161,13 +1544,15 @@ pub(crate) mod tests {
|
|
|
1161
1544
|
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
1162
1545
|
|
|
1163
1546
|
handle(
|
|
1164
|
-
|
|
1165
|
-
|
|
1547
|
+
Host::new(
|
|
1548
|
+
&client,
|
|
1549
|
+
&wasm_path(),
|
|
1550
|
+
&test_config("Banking", 1),
|
|
1551
|
+
&lambda_client::NeverInvoker,
|
|
1552
|
+
),
|
|
1166
1553
|
"Banking::Customer.Register",
|
|
1167
1554
|
register("CUST-0003"),
|
|
1168
1555
|
None,
|
|
1169
|
-
&test_config("Banking", 1),
|
|
1170
|
-
&lambda_client::NeverInvoker,
|
|
1171
1556
|
)
|
|
1172
1557
|
.await
|
|
1173
1558
|
.unwrap()
|
|
@@ -1202,31 +1587,54 @@ pub(crate) mod tests {
|
|
|
1202
1587
|
|
|
1203
1588
|
impl RecordingInvoker {
|
|
1204
1589
|
fn new() -> Self {
|
|
1205
|
-
Self {
|
|
1590
|
+
Self {
|
|
1591
|
+
calls: std::sync::Mutex::new(Vec::new()),
|
|
1592
|
+
}
|
|
1206
1593
|
}
|
|
1207
1594
|
}
|
|
1208
1595
|
|
|
1209
1596
|
#[async_trait::async_trait]
|
|
1210
1597
|
impl LambdaInvoker for RecordingInvoker {
|
|
1211
|
-
async fn invoke(
|
|
1212
|
-
self
|
|
1213
|
-
|
|
1598
|
+
async fn invoke(
|
|
1599
|
+
&self,
|
|
1600
|
+
function_name: &str,
|
|
1601
|
+
payload: &str,
|
|
1602
|
+
) -> anyhow::Result<lambda_client::InvokeOutcome> {
|
|
1603
|
+
self.calls
|
|
1604
|
+
.lock()
|
|
1605
|
+
.unwrap()
|
|
1606
|
+
.push((function_name.to_string(), payload.to_string()));
|
|
1607
|
+
Ok(lambda_client::InvokeOutcome {
|
|
1608
|
+
body: serde_json::json!({ "refusals": [] }),
|
|
1609
|
+
function_error: false,
|
|
1610
|
+
})
|
|
1214
1611
|
}
|
|
1215
1612
|
}
|
|
1216
1613
|
|
|
1217
1614
|
#[tokio::test]
|
|
1218
1615
|
async fn a_cross_domain_policy_delivers_through_the_real_rehydrate_replay_path() {
|
|
1219
1616
|
let client = scratch_db("rust_host_dispatch_test_6").await;
|
|
1220
|
-
provision_lineage(
|
|
1617
|
+
provision_lineage(
|
|
1618
|
+
&*client.lock().await,
|
|
1619
|
+
"Banking",
|
|
1620
|
+
1,
|
|
1621
|
+
&["Customer", "Account"],
|
|
1622
|
+
)
|
|
1623
|
+
.await;
|
|
1221
1624
|
let config = test_config("Banking", 1);
|
|
1222
1625
|
let invoker = RecordingInvoker::new();
|
|
1223
1626
|
|
|
1224
|
-
handle(
|
|
1225
|
-
|
|
1226
|
-
.
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1627
|
+
handle(
|
|
1628
|
+
Host::new(&client, &wasm_path(), &config, &invoker),
|
|
1629
|
+
"Banking::Customer.Register",
|
|
1630
|
+
register("CUST-0007"),
|
|
1631
|
+
None,
|
|
1632
|
+
)
|
|
1633
|
+
.await
|
|
1634
|
+
.unwrap()
|
|
1635
|
+
.accepted
|
|
1636
|
+
.then_some(())
|
|
1637
|
+
.expect("registration should succeed");
|
|
1230
1638
|
|
|
1231
1639
|
let open_args = serde_json::json!({
|
|
1232
1640
|
"number": { "value": "acct-freeze-me" },
|
|
@@ -1234,24 +1642,44 @@ pub(crate) mod tests {
|
|
|
1234
1642
|
"daily_limit": { "cents": 50000 },
|
|
1235
1643
|
"customer": "CUST-0007",
|
|
1236
1644
|
});
|
|
1237
|
-
handle(
|
|
1238
|
-
|
|
1239
|
-
.
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1645
|
+
handle(
|
|
1646
|
+
Host::new(&client, &wasm_path(), &config, &invoker),
|
|
1647
|
+
"Banking::Account.Open",
|
|
1648
|
+
open_args,
|
|
1649
|
+
None,
|
|
1650
|
+
)
|
|
1651
|
+
.await
|
|
1652
|
+
.unwrap()
|
|
1653
|
+
.accepted
|
|
1654
|
+
.then_some(())
|
|
1655
|
+
.expect("account open should succeed");
|
|
1243
1656
|
|
|
1244
1657
|
// `FreezeAccount` announces `AccountFrozen`, matched by
|
|
1245
1658
|
// `ReviewOnFreeze` (`across "Compliance"`) — the real trigger
|
|
1246
1659
|
// this feature exists for.
|
|
1247
1660
|
let freeze_args = serde_json::json!({ "number": { "value": "acct-freeze-me" } });
|
|
1248
|
-
let outcome = handle(
|
|
1249
|
-
|
|
1250
|
-
.
|
|
1251
|
-
|
|
1661
|
+
let outcome = handle(
|
|
1662
|
+
Host::new(&client, &wasm_path(), &config, &invoker),
|
|
1663
|
+
"Banking::Account.FreezeAccount",
|
|
1664
|
+
freeze_args,
|
|
1665
|
+
None,
|
|
1666
|
+
)
|
|
1667
|
+
.await
|
|
1668
|
+
.unwrap();
|
|
1669
|
+
assert!(
|
|
1670
|
+
outcome.accepted,
|
|
1671
|
+
"freeze should succeed: {:?}",
|
|
1672
|
+
outcome.result
|
|
1673
|
+
);
|
|
1252
1674
|
|
|
1253
|
-
let deliveries = outcome.result["cross_domain_deliveries"]
|
|
1254
|
-
|
|
1675
|
+
let deliveries = outcome.result["cross_domain_deliveries"]
|
|
1676
|
+
.as_array()
|
|
1677
|
+
.unwrap();
|
|
1678
|
+
assert_eq!(
|
|
1679
|
+
deliveries.len(),
|
|
1680
|
+
1,
|
|
1681
|
+
"exactly one cross-domain reaction should have fired: {deliveries:?}"
|
|
1682
|
+
);
|
|
1255
1683
|
assert_eq!(deliveries[0]["policy"], "ReviewOnFreeze");
|
|
1256
1684
|
assert_eq!(deliveries[0]["target_domain"], "Compliance");
|
|
1257
1685
|
assert_eq!(deliveries[0]["delivered"], true);
|
|
@@ -1260,7 +1688,10 @@ pub(crate) mod tests {
|
|
|
1260
1688
|
// ({policy, on, trigger, delivered, reason}), alongside the
|
|
1261
1689
|
// differently-shaped "cross_domain_deliveries" entry above.
|
|
1262
1690
|
let reactions = outcome.result["reactions"].as_array().unwrap();
|
|
1263
|
-
let merged = reactions
|
|
1691
|
+
let merged = reactions
|
|
1692
|
+
.iter()
|
|
1693
|
+
.find(|r| r["policy"] == "ReviewOnFreeze")
|
|
1694
|
+
.expect("ReviewOnFreeze should appear in \"reactions\" too");
|
|
1264
1695
|
assert_eq!(merged["on"], "AccountFrozen");
|
|
1265
1696
|
assert_eq!(merged["trigger"], "Compliance::AccountFreezeReview.Open");
|
|
1266
1697
|
assert_eq!(merged["delivered"], true);
|
|
@@ -1281,10 +1712,19 @@ pub(crate) mod tests {
|
|
|
1281
1712
|
// Never re-delivered on a later replay: `pending_cross_domain`
|
|
1282
1713
|
// only ever reads the last step's own reactions, so rehydrating
|
|
1283
1714
|
// history here doesn't re-invoke the sibling Lambda.
|
|
1284
|
-
handle(
|
|
1285
|
-
|
|
1286
|
-
.
|
|
1287
|
-
|
|
1715
|
+
handle(
|
|
1716
|
+
Host::new(&client, &wasm_path(), &config, &invoker),
|
|
1717
|
+
"Banking::Customer.Register",
|
|
1718
|
+
register("CUST-0008"),
|
|
1719
|
+
None,
|
|
1720
|
+
)
|
|
1721
|
+
.await
|
|
1722
|
+
.unwrap();
|
|
1723
|
+
assert_eq!(
|
|
1724
|
+
invoker.calls.lock().unwrap().len(),
|
|
1725
|
+
1,
|
|
1726
|
+
"replaying prior history must not re-deliver its cross-domain reaction"
|
|
1727
|
+
);
|
|
1288
1728
|
}
|
|
1289
1729
|
|
|
1290
1730
|
// Pins the fix: this is the first test that reaches `check_role`
|
|
@@ -1301,13 +1741,10 @@ pub(crate) mod tests {
|
|
|
1301
1741
|
// The correct role succeeds, same as every other registration,
|
|
1302
1742
|
// just now with a caller actually bound and matching.
|
|
1303
1743
|
let matching = handle(
|
|
1304
|
-
&client,
|
|
1305
|
-
&wasm_path(),
|
|
1744
|
+
Host::new(&client, &wasm_path(), &config, &lambda_client::NeverInvoker),
|
|
1306
1745
|
"Banking::Customer.Register",
|
|
1307
1746
|
register("CUST-0009"),
|
|
1308
1747
|
Some("Branch clerk"),
|
|
1309
|
-
&config,
|
|
1310
|
-
&lambda_client::NeverInvoker,
|
|
1311
1748
|
)
|
|
1312
1749
|
.await
|
|
1313
1750
|
.unwrap();
|
|
@@ -1321,13 +1758,10 @@ pub(crate) mod tests {
|
|
|
1321
1758
|
// caller stating the wrong role must be refused, not silently
|
|
1322
1759
|
// admitted via the unchecked no-role path.
|
|
1323
1760
|
let mismatched = handle(
|
|
1324
|
-
&client,
|
|
1325
|
-
&wasm_path(),
|
|
1761
|
+
Host::new(&client, &wasm_path(), &config, &lambda_client::NeverInvoker),
|
|
1326
1762
|
"Banking::Customer.Register",
|
|
1327
1763
|
register("CUST-0010"),
|
|
1328
1764
|
Some("Teller"),
|
|
1329
|
-
&config,
|
|
1330
|
-
&lambda_client::NeverInvoker,
|
|
1331
1765
|
)
|
|
1332
1766
|
.await
|
|
1333
1767
|
.unwrap();
|
|
@@ -1348,7 +1782,11 @@ pub(crate) mod tests {
|
|
|
1348
1782
|
// And the refused command was never persisted — same discipline
|
|
1349
1783
|
// every other refusal in this file is held to.
|
|
1350
1784
|
let steps = journal::load_steps(&*client.lock().await).await.unwrap();
|
|
1351
|
-
assert_eq!(
|
|
1785
|
+
assert_eq!(
|
|
1786
|
+
steps.len(),
|
|
1787
|
+
1,
|
|
1788
|
+
"only the correctly-authorized registration should be persisted"
|
|
1789
|
+
);
|
|
1352
1790
|
}
|
|
1353
1791
|
|
|
1354
1792
|
#[test]
|
|
@@ -1366,16 +1804,27 @@ pub(crate) mod tests {
|
|
|
1366
1804
|
assert_eq!(Enforcement::Off.effective(nobody), nobody);
|
|
1367
1805
|
for mode in [Enforcement::Shadow, Enforcement::Enforce] {
|
|
1368
1806
|
assert_eq!(mode.effective(nobody).role, Some(ANONYMOUS_ROLE));
|
|
1369
|
-
let stated = Caller {
|
|
1807
|
+
let stated = Caller {
|
|
1808
|
+
role: Some("Teller"),
|
|
1809
|
+
actor_id: None,
|
|
1810
|
+
};
|
|
1370
1811
|
assert_eq!(mode.effective(stated), stated, "a stated role is kept");
|
|
1371
|
-
let identified = Caller {
|
|
1372
|
-
|
|
1812
|
+
let identified = Caller {
|
|
1813
|
+
role: None,
|
|
1814
|
+
actor_id: Some("u1"),
|
|
1815
|
+
};
|
|
1816
|
+
assert_eq!(
|
|
1817
|
+
mode.effective(identified),
|
|
1818
|
+
identified,
|
|
1819
|
+
"an identified caller is kept"
|
|
1820
|
+
);
|
|
1373
1821
|
}
|
|
1374
1822
|
}
|
|
1375
1823
|
|
|
1376
1824
|
// The unidentified caller is the case the host never refused: no role at all.
|
|
1377
1825
|
#[tokio::test]
|
|
1378
|
-
async fn an_unidentified_caller_is_refused_when_enforced_and_let_through_when_off_or_shadowed()
|
|
1826
|
+
async fn an_unidentified_caller_is_refused_when_enforced_and_let_through_when_off_or_shadowed()
|
|
1827
|
+
{
|
|
1379
1828
|
let client = scratch_db("rust_host_dispatch_test_enforce").await;
|
|
1380
1829
|
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
1381
1830
|
let config = test_config("Banking", 1);
|
|
@@ -1383,26 +1832,52 @@ pub(crate) mod tests {
|
|
|
1383
1832
|
let client = &client;
|
|
1384
1833
|
let config = &config;
|
|
1385
1834
|
async move {
|
|
1386
|
-
dispatch_in(
|
|
1387
|
-
|
|
1388
|
-
|
|
1835
|
+
dispatch_in(
|
|
1836
|
+
mode,
|
|
1837
|
+
Origin::External,
|
|
1838
|
+
Host::new(client, &wasm_path(), config, &lambda_client::NeverInvoker),
|
|
1839
|
+
"Banking::Customer.Register",
|
|
1840
|
+
register(id),
|
|
1841
|
+
Caller::default(),
|
|
1842
|
+
)
|
|
1843
|
+
.await
|
|
1844
|
+
.unwrap()
|
|
1389
1845
|
}
|
|
1390
1846
|
};
|
|
1391
1847
|
|
|
1392
1848
|
let off = go(Enforcement::Off, "CUST-0021").await;
|
|
1393
|
-
assert!(
|
|
1849
|
+
assert!(
|
|
1850
|
+
off.accepted,
|
|
1851
|
+
"off keeps the old behaviour: {:?}",
|
|
1852
|
+
off.result
|
|
1853
|
+
);
|
|
1394
1854
|
|
|
1395
1855
|
let enforced = go(Enforcement::Enforce, "CUST-0022").await;
|
|
1396
|
-
assert!(
|
|
1856
|
+
assert!(
|
|
1857
|
+
!enforced.accepted,
|
|
1858
|
+
"an unidentified caller must be refused: {:?}",
|
|
1859
|
+
enforced.result
|
|
1860
|
+
);
|
|
1397
1861
|
assert!(refused_for_role(&enforced.result), "{:?}", enforced.result);
|
|
1398
1862
|
let error = enforced.result["refusals"][0]["error"].as_str().unwrap();
|
|
1399
|
-
assert!(
|
|
1863
|
+
assert!(
|
|
1864
|
+
error.contains("Branch clerk") && error.contains(ANONYMOUS_ROLE),
|
|
1865
|
+
"{error}"
|
|
1866
|
+
);
|
|
1400
1867
|
|
|
1401
1868
|
let shadowed = go(Enforcement::Shadow, "CUST-0023").await;
|
|
1402
|
-
assert!(
|
|
1869
|
+
assert!(
|
|
1870
|
+
shadowed.accepted,
|
|
1871
|
+
"shadow lets the command through: {:?}",
|
|
1872
|
+
shadowed.result
|
|
1873
|
+
);
|
|
1403
1874
|
|
|
1404
1875
|
let steps = journal::load_steps(&*client.lock().await).await.unwrap();
|
|
1405
|
-
assert_eq!(
|
|
1876
|
+
assert_eq!(
|
|
1877
|
+
steps.len(),
|
|
1878
|
+
2,
|
|
1879
|
+
"the enforced refusal was not persisted; off and shadow were"
|
|
1880
|
+
);
|
|
1406
1881
|
}
|
|
1407
1882
|
|
|
1408
1883
|
// Shadow may only let through what Off would: a caller that stated a wrong role is refused.
|
|
@@ -1415,24 +1890,60 @@ pub(crate) mod tests {
|
|
|
1415
1890
|
let client = &client;
|
|
1416
1891
|
let config = &config;
|
|
1417
1892
|
async move {
|
|
1418
|
-
dispatch_in(
|
|
1419
|
-
|
|
1420
|
-
|
|
1893
|
+
dispatch_in(
|
|
1894
|
+
mode,
|
|
1895
|
+
origin,
|
|
1896
|
+
Host::new(client, &wasm_path(), config, &lambda_client::NeverInvoker),
|
|
1897
|
+
"Banking::Customer.Register",
|
|
1898
|
+
register(id),
|
|
1899
|
+
caller,
|
|
1900
|
+
)
|
|
1901
|
+
.await
|
|
1902
|
+
.unwrap()
|
|
1421
1903
|
}
|
|
1422
1904
|
};
|
|
1423
|
-
let wrong = Caller {
|
|
1905
|
+
let wrong = Caller {
|
|
1906
|
+
role: Some("Auditor"),
|
|
1907
|
+
actor_id: None,
|
|
1908
|
+
};
|
|
1424
1909
|
|
|
1425
1910
|
let shadow_wrong = go(Enforcement::Shadow, Origin::External, wrong, "CUST-0031").await;
|
|
1426
|
-
assert!(
|
|
1427
|
-
|
|
1911
|
+
assert!(
|
|
1912
|
+
!shadow_wrong.accepted,
|
|
1913
|
+
"shadow must not accept a stated wrong role: {:?}",
|
|
1914
|
+
shadow_wrong.result
|
|
1915
|
+
);
|
|
1916
|
+
assert!(
|
|
1917
|
+
refused_for_role(&shadow_wrong.result),
|
|
1918
|
+
"{:?}",
|
|
1919
|
+
shadow_wrong.result
|
|
1920
|
+
);
|
|
1428
1921
|
let off_wrong = go(Enforcement::Off, Origin::External, wrong, "CUST-0032").await;
|
|
1429
|
-
assert!(
|
|
1922
|
+
assert!(
|
|
1923
|
+
!off_wrong.accepted,
|
|
1924
|
+
"off refuses it too: {:?}",
|
|
1925
|
+
off_wrong.result
|
|
1926
|
+
);
|
|
1430
1927
|
|
|
1431
|
-
let nobody = go(
|
|
1432
|
-
|
|
1928
|
+
let nobody = go(
|
|
1929
|
+
Enforcement::Shadow,
|
|
1930
|
+
Origin::External,
|
|
1931
|
+
Caller::default(),
|
|
1932
|
+
"CUST-0033",
|
|
1933
|
+
)
|
|
1934
|
+
.await;
|
|
1935
|
+
assert!(
|
|
1936
|
+
nobody.accepted,
|
|
1937
|
+
"shadow lets an unidentified caller through: {:?}",
|
|
1938
|
+
nobody.result
|
|
1939
|
+
);
|
|
1433
1940
|
|
|
1434
1941
|
let steps = journal::load_steps(&*client.lock().await).await.unwrap();
|
|
1435
|
-
assert_eq!(
|
|
1942
|
+
assert_eq!(
|
|
1943
|
+
steps.len(),
|
|
1944
|
+
1,
|
|
1945
|
+
"only the unidentified caller's registration was persisted"
|
|
1946
|
+
);
|
|
1436
1947
|
}
|
|
1437
1948
|
|
|
1438
1949
|
// A host-internal dispatch names no caller and is unchecked in every mode, while an external
|
|
@@ -1446,15 +1957,26 @@ pub(crate) mod tests {
|
|
|
1446
1957
|
let client = &client;
|
|
1447
1958
|
let config = &config;
|
|
1448
1959
|
async move {
|
|
1449
|
-
dispatch_in(
|
|
1450
|
-
|
|
1451
|
-
|
|
1960
|
+
dispatch_in(
|
|
1961
|
+
mode,
|
|
1962
|
+
origin,
|
|
1963
|
+
Host::new(client, &wasm_path(), config, &lambda_client::NeverInvoker),
|
|
1964
|
+
"Banking::Customer.Register",
|
|
1965
|
+
register(id),
|
|
1966
|
+
Caller::default(),
|
|
1967
|
+
)
|
|
1968
|
+
.await
|
|
1969
|
+
.unwrap()
|
|
1452
1970
|
}
|
|
1453
1971
|
};
|
|
1454
1972
|
|
|
1455
1973
|
let external = go(Enforcement::Enforce, Origin::External, "CUST-0041").await;
|
|
1456
1974
|
assert!(!external.accepted, "{:?}", external.result);
|
|
1457
|
-
for (mode, id) in [
|
|
1975
|
+
for (mode, id) in [
|
|
1976
|
+
(Enforcement::Enforce, "CUST-0042"),
|
|
1977
|
+
(Enforcement::Shadow, "CUST-0043"),
|
|
1978
|
+
(Enforcement::Off, "CUST-0044"),
|
|
1979
|
+
] {
|
|
1458
1980
|
let internal = go(mode, Origin::HostInternal, id).await;
|
|
1459
1981
|
assert!(internal.accepted, "{mode:?}: {:?}", internal.result);
|
|
1460
1982
|
}
|
|
@@ -1466,29 +1988,58 @@ pub(crate) mod tests {
|
|
|
1466
1988
|
// after `begin_saga` already wrote into `hecks_lambda_sagas`,
|
|
1467
1989
|
// proving that write rolls back with the rest of the transaction.
|
|
1468
1990
|
let client = scratch_db("rust_host_dispatch_test_9").await;
|
|
1469
|
-
provision_lineage(
|
|
1991
|
+
provision_lineage(
|
|
1992
|
+
&*client.lock().await,
|
|
1993
|
+
"Banking",
|
|
1994
|
+
1,
|
|
1995
|
+
&["Customer", "Account"],
|
|
1996
|
+
)
|
|
1997
|
+
.await;
|
|
1470
1998
|
let config = test_config("Banking", 1);
|
|
1471
1999
|
|
|
1472
|
-
handle(
|
|
1473
|
-
|
|
2000
|
+
handle(
|
|
2001
|
+
Host::new(&client, &wasm_path(), &config, &lambda_client::NeverInvoker),
|
|
2002
|
+
"Banking::Customer.Register",
|
|
2003
|
+
register("CUST-0012"),
|
|
2004
|
+
None,
|
|
2005
|
+
)
|
|
2006
|
+
.await
|
|
2007
|
+
.unwrap()
|
|
2008
|
+
.accepted
|
|
2009
|
+
.then_some(())
|
|
2010
|
+
.expect("registration should succeed");
|
|
1474
2011
|
|
|
1475
2012
|
let open_src = serde_json::json!({
|
|
1476
2013
|
"number": { "value": "src-rollback" }, "kind": { "name": "current" },
|
|
1477
2014
|
"daily_limit": { "cents": 50000 }, "customer": "CUST-0012",
|
|
1478
2015
|
});
|
|
1479
|
-
handle(
|
|
1480
|
-
|
|
2016
|
+
handle(
|
|
2017
|
+
Host::new(&client, &wasm_path(), &config, &lambda_client::NeverInvoker),
|
|
2018
|
+
"Banking::Account.Open",
|
|
2019
|
+
open_src,
|
|
2020
|
+
None,
|
|
2021
|
+
)
|
|
2022
|
+
.await
|
|
2023
|
+
.unwrap()
|
|
2024
|
+
.accepted
|
|
2025
|
+
.then_some(())
|
|
2026
|
+
.expect("source account open should succeed");
|
|
1481
2027
|
let open_dst = serde_json::json!({
|
|
1482
2028
|
"number": { "value": "dst-rollback" }, "kind": { "name": "current" },
|
|
1483
2029
|
"daily_limit": { "cents": 50000 }, "customer": "CUST-0012",
|
|
1484
2030
|
});
|
|
1485
|
-
handle(&client, &wasm_path(), "Banking::Account.Open", open_dst, None, &config, &lambda_client::NeverInvoker)
|
|
1486
|
-
.await.unwrap().accepted.then_some(()).expect("destination account open should succeed");
|
|
1487
2031
|
handle(
|
|
1488
|
-
&client, &wasm_path(),
|
|
1489
|
-
|
|
1490
|
-
|
|
2032
|
+
Host::new(&client, &wasm_path(), &config, &lambda_client::NeverInvoker),
|
|
2033
|
+
"Banking::Account.Open",
|
|
2034
|
+
open_dst,
|
|
2035
|
+
None,
|
|
1491
2036
|
)
|
|
2037
|
+
.await
|
|
2038
|
+
.unwrap()
|
|
2039
|
+
.accepted
|
|
2040
|
+
.then_some(())
|
|
2041
|
+
.expect("destination account open should succeed");
|
|
2042
|
+
handle(Host::new(&client, &wasm_path(), &config, &lambda_client::NeverInvoker), "Banking::Account.Credit", serde_json::json!({ "number": "src-rollback", "amount": { "cents": 1000 }, "narrative": { "text": "opening balance" } }), None)
|
|
1492
2043
|
.await.unwrap().accepted.then_some(()).expect("credit should succeed");
|
|
1493
2044
|
|
|
1494
2045
|
// `Transfer.Request`'s own mutation is the first one this step
|
|
@@ -1498,11 +2049,26 @@ pub(crate) mod tests {
|
|
|
1498
2049
|
"reference": { "value": "tr-rollback" }, "amount": { "cents": 200 },
|
|
1499
2050
|
"narrative": { "text": "rollback test" }, "source": "src-rollback", "destination": "dst-rollback",
|
|
1500
2051
|
});
|
|
1501
|
-
let outcome = handle(
|
|
1502
|
-
|
|
2052
|
+
let outcome = handle(
|
|
2053
|
+
Host::new(&client, &wasm_path(), &config, &lambda_client::NeverInvoker),
|
|
2054
|
+
"Banking::Transfer.Request",
|
|
2055
|
+
transfer_args,
|
|
2056
|
+
None,
|
|
2057
|
+
)
|
|
2058
|
+
.await;
|
|
2059
|
+
assert!(
|
|
2060
|
+
outcome.is_err(),
|
|
2061
|
+
"the unprovisioned Transfer lineage table should make this whole invocation fail"
|
|
2062
|
+
);
|
|
1503
2063
|
|
|
1504
2064
|
let guard = client.lock().await;
|
|
1505
|
-
let saga_rows = guard
|
|
2065
|
+
let saga_rows = guard
|
|
2066
|
+
.query(
|
|
2067
|
+
"SELECT process_manager, correlation FROM hecks_lambda_sagas",
|
|
2068
|
+
&[],
|
|
2069
|
+
)
|
|
2070
|
+
.await
|
|
2071
|
+
.unwrap();
|
|
1506
2072
|
assert_eq!(
|
|
1507
2073
|
saga_rows.len(), 0,
|
|
1508
2074
|
"a saga write inside a transaction that ultimately fails must roll back with it, not commit independently: {saga_rows:?}"
|
|
@@ -1512,49 +2078,102 @@ pub(crate) mod tests {
|
|
|
1512
2078
|
#[tokio::test]
|
|
1513
2079
|
async fn a_pre_existing_snapshot_without_the_sagas_backfilled_latch_forces_one_full_replay() {
|
|
1514
2080
|
let client = scratch_db("rust_host_dispatch_test_10").await;
|
|
1515
|
-
provision_lineage(
|
|
2081
|
+
provision_lineage(
|
|
2082
|
+
&*client.lock().await,
|
|
2083
|
+
"Banking",
|
|
2084
|
+
1,
|
|
2085
|
+
&["Customer", "Account", "Transfer"],
|
|
2086
|
+
)
|
|
2087
|
+
.await;
|
|
1516
2088
|
let config = test_config("Banking", 1);
|
|
1517
2089
|
// `ReviewOnFreeze` fires on every `FreezeAccount` in this domain
|
|
1518
2090
|
// (proven above), so `NeverInvoker` would panic here.
|
|
1519
2091
|
let invoker = RecordingInvoker::new();
|
|
1520
2092
|
|
|
1521
|
-
handle(
|
|
1522
|
-
|
|
2093
|
+
handle(
|
|
2094
|
+
Host::new(&client, &wasm_path(), &config, &invoker),
|
|
2095
|
+
"Banking::Customer.Register",
|
|
2096
|
+
register("CUST-0013"),
|
|
2097
|
+
None,
|
|
2098
|
+
)
|
|
2099
|
+
.await
|
|
2100
|
+
.unwrap()
|
|
2101
|
+
.accepted
|
|
2102
|
+
.then_some(())
|
|
2103
|
+
.expect("registration should succeed");
|
|
1523
2104
|
|
|
1524
2105
|
let open_src = serde_json::json!({
|
|
1525
2106
|
"number": { "value": "src-backfill" }, "kind": { "name": "current" },
|
|
1526
2107
|
"daily_limit": { "cents": 50000 }, "customer": "CUST-0013",
|
|
1527
2108
|
});
|
|
1528
|
-
handle(
|
|
1529
|
-
|
|
2109
|
+
handle(
|
|
2110
|
+
Host::new(&client, &wasm_path(), &config, &invoker),
|
|
2111
|
+
"Banking::Account.Open",
|
|
2112
|
+
open_src,
|
|
2113
|
+
None,
|
|
2114
|
+
)
|
|
2115
|
+
.await
|
|
2116
|
+
.unwrap()
|
|
2117
|
+
.accepted
|
|
2118
|
+
.then_some(())
|
|
2119
|
+
.expect("source account open should succeed");
|
|
1530
2120
|
let open_dst = serde_json::json!({
|
|
1531
2121
|
"number": { "value": "dst-backfill" }, "kind": { "name": "current" },
|
|
1532
2122
|
"daily_limit": { "cents": 50000 }, "customer": "CUST-0013",
|
|
1533
2123
|
});
|
|
1534
|
-
handle(&client, &wasm_path(), "Banking::Account.Open", open_dst, None, &config, &invoker)
|
|
1535
|
-
.await.unwrap().accepted.then_some(()).expect("destination account open should succeed");
|
|
1536
2124
|
handle(
|
|
1537
|
-
&client, &wasm_path(),
|
|
1538
|
-
|
|
1539
|
-
|
|
2125
|
+
Host::new(&client, &wasm_path(), &config, &invoker),
|
|
2126
|
+
"Banking::Account.Open",
|
|
2127
|
+
open_dst,
|
|
2128
|
+
None,
|
|
1540
2129
|
)
|
|
2130
|
+
.await
|
|
2131
|
+
.unwrap()
|
|
2132
|
+
.accepted
|
|
2133
|
+
.then_some(())
|
|
2134
|
+
.expect("destination account open should succeed");
|
|
2135
|
+
handle(Host::new(&client, &wasm_path(), &config, &invoker), "Banking::Account.Credit", serde_json::json!({ "number": "src-backfill", "amount": { "cents": 1000 }, "narrative": { "text": "opening balance" } }), None)
|
|
1541
2136
|
.await.unwrap().accepted.then_some(()).expect("credit should succeed");
|
|
1542
2137
|
|
|
1543
2138
|
// Freezing the destination first means the credit leg refuses
|
|
1544
2139
|
// and Settlement compensates, leaving the saga stuck "reversed"
|
|
1545
2140
|
// — exactly the mid-flight shape this test needs to recover.
|
|
1546
|
-
handle(
|
|
1547
|
-
|
|
2141
|
+
handle(
|
|
2142
|
+
Host::new(&client, &wasm_path(), &config, &invoker),
|
|
2143
|
+
"Banking::Account.FreezeAccount",
|
|
2144
|
+
serde_json::json!({ "number": { "value": "dst-backfill" } }),
|
|
2145
|
+
None,
|
|
2146
|
+
)
|
|
2147
|
+
.await
|
|
2148
|
+
.unwrap()
|
|
2149
|
+
.accepted
|
|
2150
|
+
.then_some(())
|
|
2151
|
+
.expect("freeze should succeed");
|
|
1548
2152
|
let transfer_args = serde_json::json!({
|
|
1549
2153
|
"reference": { "value": "tr-backfill" }, "amount": { "cents": 200 },
|
|
1550
2154
|
"narrative": { "text": "backfill test" }, "source": "src-backfill", "destination": "dst-backfill",
|
|
1551
2155
|
});
|
|
1552
|
-
handle(
|
|
1553
|
-
|
|
2156
|
+
handle(
|
|
2157
|
+
Host::new(&client, &wasm_path(), &config, &invoker),
|
|
2158
|
+
"Banking::Transfer.Request",
|
|
2159
|
+
transfer_args,
|
|
2160
|
+
None,
|
|
2161
|
+
)
|
|
2162
|
+
.await
|
|
2163
|
+
.unwrap()
|
|
2164
|
+
.accepted
|
|
2165
|
+
.then_some(())
|
|
2166
|
+
.expect("the Transfer.Request COMMAND succeeds; the saga's own credit leg is what refuses");
|
|
1554
2167
|
|
|
1555
2168
|
{
|
|
1556
2169
|
let guard = client.lock().await;
|
|
1557
|
-
let before = guard
|
|
2170
|
+
let before = guard
|
|
2171
|
+
.query(
|
|
2172
|
+
"SELECT process_manager, correlation, state FROM hecks_lambda_sagas",
|
|
2173
|
+
&[],
|
|
2174
|
+
)
|
|
2175
|
+
.await
|
|
2176
|
+
.unwrap();
|
|
1558
2177
|
assert_eq!(before.len(), 1, "the reversed Settlement instance should already be tracked before the simulated reset: {before:?}");
|
|
1559
2178
|
}
|
|
1560
2179
|
|
|
@@ -1563,18 +2182,42 @@ pub(crate) mod tests {
|
|
|
1563
2182
|
// mid-flight.
|
|
1564
2183
|
{
|
|
1565
2184
|
let guard = client.lock().await;
|
|
1566
|
-
guard
|
|
1567
|
-
|
|
2185
|
+
guard
|
|
2186
|
+
.execute(
|
|
2187
|
+
"UPDATE hecks_lambda_snapshot SET sagas_backfilled = false",
|
|
2188
|
+
&[],
|
|
2189
|
+
)
|
|
2190
|
+
.await
|
|
2191
|
+
.unwrap();
|
|
2192
|
+
guard
|
|
2193
|
+
.execute("DELETE FROM hecks_lambda_sagas", &[])
|
|
2194
|
+
.await
|
|
2195
|
+
.unwrap();
|
|
1568
2196
|
}
|
|
1569
2197
|
|
|
1570
2198
|
// One more command should trigger a full replay, whose saga
|
|
1571
2199
|
// cascade re-running as a side effect repopulates
|
|
1572
2200
|
// `hecks_lambda_sagas`.
|
|
1573
|
-
handle(
|
|
1574
|
-
|
|
2201
|
+
handle(
|
|
2202
|
+
Host::new(&client, &wasm_path(), &config, &invoker),
|
|
2203
|
+
"Banking::Customer.Register",
|
|
2204
|
+
register("CUST-0014"),
|
|
2205
|
+
None,
|
|
2206
|
+
)
|
|
2207
|
+
.await
|
|
2208
|
+
.unwrap()
|
|
2209
|
+
.accepted
|
|
2210
|
+
.then_some(())
|
|
2211
|
+
.expect("second registration should succeed");
|
|
1575
2212
|
|
|
1576
2213
|
let guard = client.lock().await;
|
|
1577
|
-
let saga_rows = guard
|
|
2214
|
+
let saga_rows = guard
|
|
2215
|
+
.query(
|
|
2216
|
+
"SELECT process_manager, correlation, state FROM hecks_lambda_sagas",
|
|
2217
|
+
&[],
|
|
2218
|
+
)
|
|
2219
|
+
.await
|
|
2220
|
+
.unwrap();
|
|
1578
2221
|
assert_eq!(saga_rows.len(), 1, "the backfill replay should have recovered the in-flight Settlement instance: {saga_rows:?}");
|
|
1579
2222
|
let process_manager: String = saga_rows[0].get(0);
|
|
1580
2223
|
let correlation: String = saga_rows[0].get(1);
|
|
@@ -1583,7 +2226,11 @@ pub(crate) mod tests {
|
|
|
1583
2226
|
assert_eq!(correlation, "tr-backfill");
|
|
1584
2227
|
assert_eq!(state, "reversed");
|
|
1585
2228
|
|
|
1586
|
-
let latched: bool = guard
|
|
2229
|
+
let latched: bool = guard
|
|
2230
|
+
.query_one("SELECT sagas_backfilled FROM hecks_lambda_snapshot", &[])
|
|
2231
|
+
.await
|
|
2232
|
+
.unwrap()
|
|
2233
|
+
.get(0);
|
|
1587
2234
|
assert!(latched, "after the backfill replay, the latch should be set so this doesn't repeat every invocation");
|
|
1588
2235
|
}
|
|
1589
2236
|
|
|
@@ -1595,31 +2242,49 @@ pub(crate) mod tests {
|
|
|
1595
2242
|
|
|
1596
2243
|
impl AlwaysFailingInvoker {
|
|
1597
2244
|
fn new() -> Self {
|
|
1598
|
-
Self {
|
|
2245
|
+
Self {
|
|
2246
|
+
calls: std::sync::Mutex::new(0),
|
|
2247
|
+
}
|
|
1599
2248
|
}
|
|
1600
2249
|
}
|
|
1601
2250
|
|
|
1602
2251
|
#[async_trait::async_trait]
|
|
1603
2252
|
impl LambdaInvoker for AlwaysFailingInvoker {
|
|
1604
|
-
async fn invoke(
|
|
2253
|
+
async fn invoke(
|
|
2254
|
+
&self,
|
|
2255
|
+
_function_name: &str,
|
|
2256
|
+
_payload: &str,
|
|
2257
|
+
) -> anyhow::Result<lambda_client::InvokeOutcome> {
|
|
1605
2258
|
*self.calls.lock().unwrap() += 1;
|
|
1606
2259
|
anyhow::bail!("ResourceNotFoundException: function not found")
|
|
1607
2260
|
}
|
|
1608
2261
|
}
|
|
1609
2262
|
|
|
1610
2263
|
#[tokio::test]
|
|
1611
|
-
async fn a_cross_domain_delivery_that_exhausts_every_retry_dead_letters_and_still_fails_the_invocation(
|
|
2264
|
+
async fn a_cross_domain_delivery_that_exhausts_every_retry_dead_letters_and_still_fails_the_invocation(
|
|
2265
|
+
) {
|
|
1612
2266
|
let client = scratch_db("rust_host_dispatch_test_8").await;
|
|
1613
|
-
provision_lineage(
|
|
2267
|
+
provision_lineage(
|
|
2268
|
+
&*client.lock().await,
|
|
2269
|
+
"Banking",
|
|
2270
|
+
1,
|
|
2271
|
+
&["Customer", "Account"],
|
|
2272
|
+
)
|
|
2273
|
+
.await;
|
|
1614
2274
|
let config = test_config("Banking", 1);
|
|
1615
2275
|
let invoker = AlwaysFailingInvoker::new();
|
|
1616
2276
|
|
|
1617
|
-
handle(
|
|
1618
|
-
|
|
1619
|
-
.
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
2277
|
+
handle(
|
|
2278
|
+
Host::new(&client, &wasm_path(), &config, &invoker),
|
|
2279
|
+
"Banking::Customer.Register",
|
|
2280
|
+
register("CUST-0011"),
|
|
2281
|
+
None,
|
|
2282
|
+
)
|
|
2283
|
+
.await
|
|
2284
|
+
.unwrap()
|
|
2285
|
+
.accepted
|
|
2286
|
+
.then_some(())
|
|
2287
|
+
.expect("registration should succeed");
|
|
1623
2288
|
|
|
1624
2289
|
let open_args = serde_json::json!({
|
|
1625
2290
|
"number": { "value": "acct-freeze-dead-letter" },
|
|
@@ -1627,18 +2292,29 @@ pub(crate) mod tests {
|
|
|
1627
2292
|
"daily_limit": { "cents": 50000 },
|
|
1628
2293
|
"customer": "CUST-0011",
|
|
1629
2294
|
});
|
|
1630
|
-
handle(
|
|
1631
|
-
|
|
1632
|
-
.
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
2295
|
+
handle(
|
|
2296
|
+
Host::new(&client, &wasm_path(), &config, &invoker),
|
|
2297
|
+
"Banking::Account.Open",
|
|
2298
|
+
open_args,
|
|
2299
|
+
None,
|
|
2300
|
+
)
|
|
2301
|
+
.await
|
|
2302
|
+
.unwrap()
|
|
2303
|
+
.accepted
|
|
2304
|
+
.then_some(())
|
|
2305
|
+
.expect("account open should succeed");
|
|
1636
2306
|
|
|
1637
2307
|
// `ReviewOnFreeze` fires here — every attempt against
|
|
1638
2308
|
// `AlwaysFailingInvoker` fails, so `deliver_with_retry` exhausts
|
|
1639
2309
|
// `MAX_DELIVERY_ATTEMPTS` before `handle` ever sees a result.
|
|
1640
2310
|
let freeze_args = serde_json::json!({ "number": { "value": "acct-freeze-dead-letter" } });
|
|
1641
|
-
let outcome = handle(
|
|
2311
|
+
let outcome = handle(
|
|
2312
|
+
Host::new(&client, &wasm_path(), &config, &invoker),
|
|
2313
|
+
"Banking::Account.FreezeAccount",
|
|
2314
|
+
freeze_args,
|
|
2315
|
+
None,
|
|
2316
|
+
)
|
|
2317
|
+
.await;
|
|
1642
2318
|
|
|
1643
2319
|
assert!(
|
|
1644
2320
|
outcome.is_err(),
|
|
@@ -1660,7 +2336,11 @@ pub(crate) mod tests {
|
|
|
1660
2336
|
.query("SELECT policy, target_domain, target_verb, attempts FROM hecks_cross_domain_dead_letters", &[])
|
|
1661
2337
|
.await
|
|
1662
2338
|
.unwrap();
|
|
1663
|
-
assert_eq!(
|
|
2339
|
+
assert_eq!(
|
|
2340
|
+
dead_letters.len(),
|
|
2341
|
+
1,
|
|
2342
|
+
"exactly one exhausted delivery should have been dead-lettered: {dead_letters:?}"
|
|
2343
|
+
);
|
|
1664
2344
|
let policy: String = dead_letters[0].get(0);
|
|
1665
2345
|
let target_domain: String = dead_letters[0].get(1);
|
|
1666
2346
|
let target_verb: String = dead_letters[0].get(2);
|
|
@@ -1679,16 +2359,27 @@ pub(crate) mod tests {
|
|
|
1679
2359
|
#[tokio::test]
|
|
1680
2360
|
async fn a_failed_cross_domain_delivery_does_not_drop_a_sibling_reaction_from_the_same_step() {
|
|
1681
2361
|
let client = scratch_db("rust_host_dispatch_test_11").await;
|
|
1682
|
-
provision_lineage(
|
|
2362
|
+
provision_lineage(
|
|
2363
|
+
&*client.lock().await,
|
|
2364
|
+
"Banking",
|
|
2365
|
+
1,
|
|
2366
|
+
&["Customer", "SafeDepositBox"],
|
|
2367
|
+
)
|
|
2368
|
+
.await;
|
|
1683
2369
|
let config = test_config("Banking", 1);
|
|
1684
2370
|
let invoker = AlwaysFailingInvoker::new();
|
|
1685
2371
|
|
|
1686
|
-
handle(
|
|
1687
|
-
|
|
1688
|
-
.
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
2372
|
+
handle(
|
|
2373
|
+
Host::new(&client, &wasm_path(), &config, &invoker),
|
|
2374
|
+
"Banking::Customer.Register",
|
|
2375
|
+
register("CUST-0022"),
|
|
2376
|
+
None,
|
|
2377
|
+
)
|
|
2378
|
+
.await
|
|
2379
|
+
.unwrap()
|
|
2380
|
+
.accepted
|
|
2381
|
+
.then_some(())
|
|
2382
|
+
.expect("registration should succeed");
|
|
1692
2383
|
|
|
1693
2384
|
let rent_args = serde_json::json!({
|
|
1694
2385
|
"branch_code": { "value": "downtown" },
|
|
@@ -1696,22 +2387,34 @@ pub(crate) mod tests {
|
|
|
1696
2387
|
"size": { "value": "small" },
|
|
1697
2388
|
"customer": "CUST-0022",
|
|
1698
2389
|
});
|
|
1699
|
-
handle(
|
|
1700
|
-
|
|
1701
|
-
.
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
2390
|
+
handle(
|
|
2391
|
+
Host::new(&client, &wasm_path(), &config, &invoker),
|
|
2392
|
+
"Banking::SafeDepositBox.Rent",
|
|
2393
|
+
rent_args,
|
|
2394
|
+
None,
|
|
2395
|
+
)
|
|
2396
|
+
.await
|
|
2397
|
+
.unwrap()
|
|
2398
|
+
.accepted
|
|
2399
|
+
.then_some(())
|
|
2400
|
+
.expect("rent should succeed");
|
|
1705
2401
|
|
|
1706
2402
|
// Both ReviewOnBoxSurrender (-> Compliance) and FlagKeyReturn
|
|
1707
2403
|
// (-> Notifications) fire here; both deliveries exhaust every
|
|
1708
2404
|
// retry against AlwaysFailingInvoker.
|
|
1709
2405
|
let surrender_args = serde_json::json!({ "branch_code": { "value": "downtown" }, "box_number": { "value": 12 } });
|
|
1710
|
-
let outcome =
|
|
1711
|
-
|
|
1712
|
-
|
|
2406
|
+
let outcome = handle(
|
|
2407
|
+
Host::new(&client, &wasm_path(), &config, &invoker),
|
|
2408
|
+
"Banking::SafeDepositBox.Surrender",
|
|
2409
|
+
surrender_args,
|
|
2410
|
+
None,
|
|
2411
|
+
)
|
|
2412
|
+
.await;
|
|
1713
2413
|
|
|
1714
|
-
assert!(
|
|
2414
|
+
assert!(
|
|
2415
|
+
outcome.is_err(),
|
|
2416
|
+
"still fails the invocation visibly, same contract as a single failed delivery"
|
|
2417
|
+
);
|
|
1715
2418
|
assert_eq!(
|
|
1716
2419
|
*invoker.calls.lock().unwrap(),
|
|
1717
2420
|
lambda_client::MAX_DELIVERY_ATTEMPTS * 2,
|
|
@@ -1721,7 +2424,10 @@ pub(crate) mod tests {
|
|
|
1721
2424
|
|
|
1722
2425
|
let guard = client.lock().await;
|
|
1723
2426
|
let dead_letters = guard
|
|
1724
|
-
.query(
|
|
2427
|
+
.query(
|
|
2428
|
+
"SELECT policy FROM hecks_cross_domain_dead_letters ORDER BY policy",
|
|
2429
|
+
&[],
|
|
2430
|
+
)
|
|
1725
2431
|
.await
|
|
1726
2432
|
.unwrap();
|
|
1727
2433
|
let policies: Vec<String> = dead_letters.iter().map(|row| row.get(0)).collect();
|