hecks 2.9.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/exe/hecks +51 -6
- data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/lambda.rb +1 -1
- data/lib/hecks/adapters/driven/memory.rb +16 -3
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +6 -2
- data/lib/hecks/adapters/driven/postgres/shared_connection.rb +180 -0
- data/lib/hecks/adapters/driven/postgres.rb +13 -4
- data/lib/hecks/adapters/driven/prism.rb +41 -8
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +73 -16
- data/lib/hecks/behaviors/expectations.rb +1 -1
- data/lib/hecks/behaviors.rb +1 -1
- data/lib/hecks/bench/cli.rb +62 -0
- data/lib/hecks/bench/environment.rb +71 -0
- data/lib/hecks/bench/postgres_probe.rb +30 -0
- data/lib/hecks/bench/report.rb +76 -0
- data/lib/hecks/bench/ruby_runner.rb +93 -0
- data/lib/hecks/bench/run.rb +55 -0
- data/lib/hecks/bench/rust_runner.rb +101 -0
- data/lib/hecks/bench/stats.rb +46 -0
- data/lib/hecks/bench/suite.rb +98 -0
- data/lib/hecks/bench/workload.rb +89 -0
- data/lib/hecks/bench.rb +17 -0
- data/lib/hecks/bluebook/assembly/contracts.rb +2 -0
- data/lib/hecks/bluebook/attribute.rb +1 -1
- data/lib/hecks/bluebook/behaviour/aggregate.rb +30 -1
- data/lib/hecks/bluebook/behaviour/domain_port.rb +7 -0
- data/lib/hecks/bluebook/chapter.rb +6 -2
- data/lib/hecks/bluebook/domain_port.rb +36 -5
- data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +10 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +8 -0
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -1
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +31 -3
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +122 -3
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/query_builder.rb +27 -1
- data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +29 -2
- data/lib/hecks/bluebook/expression/canonical_form.rb +1 -1
- data/lib/hecks/bluebook/expression/resolver.rb +3 -2
- data/lib/hecks/bluebook/hexagon.rb +13 -5
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -0
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +4 -1
- data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +246 -0
- data/lib/hecks/bluebook/meta_validator.rb +9 -4
- data/lib/hecks/bluebook/model_check.rb +23 -0
- data/lib/hecks/bluebook/pattern_subset.rb +38 -0
- data/lib/hecks/bluebook/query.rb +20 -5
- data/lib/hecks/bluebook/smoke_test.rb +1 -1
- data/lib/hecks/canonical_json.rb +30 -0
- data/lib/hecks/chapters.rb +102 -0
- data/lib/hecks/cli/behaviors.rb +61 -0
- data/lib/hecks/cli/canonicalise.rb +25 -0
- data/lib/hecks/cli/check_engine_agreement.rb +32 -0
- data/lib/hecks/cli/check_era.rb +88 -0
- data/lib/hecks/cli/console.rb +66 -0
- data/lib/hecks/cli/deploy_template_diff.rb +40 -0
- data/lib/hecks/cli/doc_coverage.rb +32 -0
- data/lib/hecks/cli/document.rb +2 -2
- data/lib/hecks/cli/expression_projection.rb +41 -0
- data/lib/hecks/cli/follow.rb +88 -0
- data/lib/hecks/cli/history.rb +44 -0
- data/lib/hecks/cli/ir.rb +1 -1
- data/lib/hecks/cli/mcp.rb +3 -3
- data/lib/hecks/cli/model_check.rb +15 -8
- data/lib/hecks/cli/pattern_cases.rb +47 -0
- data/lib/hecks/cli/present.rb +39 -0
- data/lib/hecks/cli/project_cli.rb +224 -32
- data/lib/hecks/cli/project_diagrams.rb +4 -3
- data/lib/hecks/cli/project_glossary.rb +44 -0
- data/lib/hecks/cli/reference.rb +46 -0
- data/lib/hecks/cli/refresh_projections.rb +44 -0
- data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +150 -0
- data/lib/hecks/cli/release.rb +70 -0
- data/lib/hecks/cli/release_gem.rb +59 -0
- data/lib/hecks/cli/rspec_io_parallel_files.rb +137 -0
- data/lib/hecks/cli/rspec_shard_files.rb +66 -0
- data/lib/hecks/cli/run.rb +3 -3
- data/lib/hecks/cli/seed_semantics_corpus.rb +71 -0
- data/lib/hecks/cli/serve_query_ir_mcp.rb +30 -0
- data/lib/hecks/cli/shape.rb +70 -0
- data/lib/hecks/cli/smoke_http.rb +230 -0
- data/lib/hecks/cli/smoke_test.rb +5 -3
- data/lib/hecks/cli/spec_example.rb +27 -0
- data/lib/hecks/cli/statements.rb +32 -0
- data/lib/hecks/cli/stores.rb +4 -5
- data/lib/hecks/cli/stress_concurrency_specs.rb +177 -0
- data/lib/hecks/cli.rb +201 -52
- data/lib/hecks/codemod.rb +324 -0
- data/lib/hecks/corpus.rb +423 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +591 -23
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +41 -4
- data/lib/hecks/deploy/oidc.json +82 -2
- data/lib/hecks/doc/reference.rb +351 -0
- data/lib/hecks/doors/cli_door.rb +196 -0
- data/lib/hecks/doors/cli_runner.rb +297 -0
- data/lib/hecks/{facade → doors}/command_request.rb +1 -1
- data/lib/hecks/{facade → doors}/handle.rb +2 -2
- data/lib/hecks/{facade → doors}/json_door.rb +4 -4
- data/lib/hecks/doors/launcher_options.rb +129 -0
- data/lib/hecks/{cli → doors}/mcp_door.rb +7 -7
- data/lib/hecks/doors/mcp_door_scope.rb +97 -0
- data/lib/hecks/{facade/surface → doors/ruby_door}/aggregate_door.rb +8 -3
- data/lib/hecks/{facade/surface → doors/ruby_door}/chapter.rb +3 -3
- data/lib/hecks/{facade/surface.rb → doors/ruby_door.rb} +36 -5
- data/lib/hecks/doors.rb +61 -0
- data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +1 -1
- data/lib/hecks/embryonaut_bluebook.rb +1 -1
- data/lib/hecks/engine_agreement.rb +156 -0
- data/lib/hecks/facade.rb +24 -37
- data/lib/hecks/forms/app.rb +6 -6
- data/lib/hecks/forms/banking_presentation.rb +50 -0
- data/lib/hecks/forms/field_shape.rb +2 -1
- data/lib/hecks/forms/port_argument.rb +1 -1
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +393 -0
- data/lib/hecks/fuzzing/combination_miner.rb +86 -0
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +214 -0
- data/lib/hecks/fuzzing/coverage_campaign.rb +143 -0
- data/lib/hecks/fuzzing/differential.rb +156 -0
- data/lib/hecks/fuzzing/domain_generator.rb +615 -0
- data/lib/hecks/fuzzing/era_boundary.rb +71 -0
- data/lib/hecks/fuzzing/form_census.rb +136 -0
- data/lib/hecks/fuzzing/generated_domain_check.rb +93 -0
- data/lib/hecks/fuzzing/invalid_value_generator.rb +64 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +301 -0
- data/lib/hecks/fuzzing/nondeterministic.rb +51 -0
- data/lib/hecks/fuzzing/outside_world.rb +45 -0
- data/lib/hecks/fuzzing/persistence_parity.rb +102 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +88 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +314 -0
- data/lib/hecks/fuzzing/properties/guards.rb +190 -0
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +300 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +93 -0
- data/lib/hecks/fuzzing/properties/outbox.rb +89 -0
- data/lib/hecks/fuzzing/properties/querying.rb +175 -0
- data/lib/hecks/fuzzing/properties.rb +162 -0
- data/lib/hecks/fuzzing/qa_settings.rb +123 -0
- data/lib/hecks/fuzzing/replay.rb +505 -0
- data/lib/hecks/fuzzing/rotation_priority.rb +48 -0
- data/lib/hecks/fuzzing/rust_gap_manifest.rb +108 -0
- data/lib/hecks/fuzzing/self_consistency.rb +339 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +385 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +132 -0
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +95 -0
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +77 -0
- data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +109 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +237 -0
- data/lib/hecks/fuzzing/sequence_generator.rb +189 -0
- data/lib/hecks/fuzzing/shrinker.rb +166 -0
- data/lib/hecks/fuzzing/structural_skips.rb +33 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +43 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +163 -0
- data/lib/hecks/fuzzing/value_generator.rb +216 -0
- data/lib/hecks/fuzzing.rb +23 -0
- data/lib/hecks/grammar/evolve.rb +336 -0
- data/lib/hecks/hecks/adapters/codebase/codemods.rb +45 -0
- data/lib/hecks/hecks/adapters/codebase/conformance.rb +71 -0
- data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +202 -0
- data/lib/hecks/hecks/adapters/codebase/gem_registry.rb +80 -0
- data/lib/hecks/hecks/adapters/codebase/kernel_tables.rb +54 -0
- data/lib/hecks/hecks/adapters/codebase/language.rb +240 -0
- data/lib/hecks/hecks/adapters/codebase/npm_registry.rb +102 -0
- data/lib/hecks/hecks/adapters/codebase/publishing.rb +142 -0
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +67 -0
- data/lib/hecks/hecks/adapters/codebase/release_facts.rb +90 -0
- data/lib/hecks/hecks/adapters/codebase/ruby_child.rb +83 -0
- data/lib/hecks/hecks/adapters/codebase/secret_vault.rb +36 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.adapter +7 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +189 -0
- data/lib/hecks/hecks/adapters/codebase/sqlite_fixture.rb +71 -0
- data/lib/hecks/hecks/adapters/codebase/style.rb +171 -0
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +55 -0
- data/lib/hecks/hecks/adapters/codebase/test_suite.rb +165 -0
- data/lib/hecks/hecks/adapters/codebase/tree.rb +111 -0
- data/lib/hecks/hecks/adapters/console_capture.rb +79 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +108 -0
- data/lib/hecks/hecks/adapters/git.adapter +7 -0
- data/lib/hecks/hecks/adapters/git.rb +102 -0
- data/lib/hecks/hecks/adapters/host_http.adapter +7 -0
- data/lib/hecks/hecks/adapters/host_http.rb +65 -0
- data/lib/hecks/hecks/adapters/in_process_boot.adapter +7 -0
- data/lib/hecks/hecks/adapters/in_process_boot.rb +197 -0
- data/lib/hecks/hecks/adapters/in_process_operations.rb +202 -0
- data/lib/hecks/hecks/adapters/journal_store/changes.rb +143 -0
- data/lib/hecks/hecks/adapters/journal_store/compaction.rb +145 -0
- data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +132 -0
- data/lib/hecks/hecks/adapters/journal_store/examination.rb +135 -0
- data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +163 -0
- data/lib/hecks/hecks/adapters/journal_store/readings.rb +187 -0
- data/lib/hecks/hecks/adapters/journal_store.adapter +7 -0
- data/lib/hecks/hecks/adapters/journal_store.rb +64 -0
- data/lib/hecks/hecks/adapters/local_files.adapter +7 -0
- data/lib/hecks/hecks/adapters/local_files.rb +46 -0
- data/lib/hecks/hecks/adapters/pg_admin.adapter +7 -0
- data/lib/hecks/hecks/adapters/pg_admin.rb +245 -0
- data/lib/hecks/hecks/adapters/process_pool.adapter +7 -0
- data/lib/hecks/hecks/adapters/process_pool.rb +119 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.rb +179 -0
- data/lib/hecks/hecks/adapters/rust_workspace.rb +150 -0
- data/lib/hecks/hecks/adapters/shell.adapter +7 -0
- data/lib/hecks/hecks/adapters/shell.rb +49 -0
- data/lib/hecks/hecks/adapters/terminal.adapter +7 -0
- data/lib/hecks/hecks/adapters/terminal.rb +74 -0
- data/lib/hecks/hecks/codebase.behaviors +172 -0
- data/lib/hecks/hecks/codebase.bluebook +2453 -0
- data/lib/hecks/hecks/context_map.hecksagon +13 -0
- data/lib/hecks/hecks/custodian.behaviors +179 -0
- data/lib/hecks/hecks/custodian.bluebook +1847 -0
- data/lib/hecks/hecks/deploy.behaviors +74 -0
- data/lib/hecks/hecks/deploy.world +12 -0
- data/lib/hecks/hecks/environments/memory.world +10 -0
- data/lib/hecks/hecks/governance.world +7 -0
- data/lib/hecks/hecks/hecks.behaviors +62 -0
- data/lib/hecks/hecks/hecks.bluebook +124 -0
- data/lib/hecks/hecks/hecks.hecksagon +390 -0
- data/lib/hecks/hecks/hecks.world +42 -0
- data/lib/hecks/kernel_capabilities.rb +194 -0
- data/lib/hecks/language/bluebook/bluebook.bluebook +11 -0
- data/lib/hecks/language/bluebook/query.bluebook +7 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -4
- data/lib/hecks/language/hecksagon/domain_port.bluebook +10 -0
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +20 -0
- data/lib/hecks/mcp_stdio_guard.rb +2 -2
- data/lib/hecks/persistence_legacy_fixture/regenerate.rb +161 -0
- data/lib/hecks/persistence_legacy_fixture.rb +113 -0
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +8 -5
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +4 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +15 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +32 -15
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +15 -5
- data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +227 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +43 -24
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +1 -0
- data/lib/hecks/ports/persistence/state_codec.rb +24 -5
- data/lib/hecks/projection_files.rb +285 -0
- data/lib/hecks/projections/bootstrap_table.rb +1 -1
- data/lib/hecks/projections/deploy/fargate.rb +5 -5
- data/lib/hecks/projections/deploy/lambda.rb +43 -32
- data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
- data/lib/hecks/projections/deploy/preview/template.rb +1 -1
- data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/scripts/expected-era.header +1 -1
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +3 -3
- data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/shared.rb +1 -1
- data/lib/hecks/projections/diagrams.rb +3 -3
- data/lib/hecks/projections/model/deviations.rb +1 -1
- data/lib/hecks/projections/parser_table.rb +4 -4
- data/lib/hecks/projections/reference.rb +2 -3
- data/lib/hecks/projections/rust_vocabulary.rb +1 -1
- data/lib/hecks/projector/cli_projector.rb +106 -37
- data/lib/hecks/projector.rb +1 -1
- data/lib/hecks/quality_control/adapters/agent.adapter +7 -0
- data/lib/hecks/quality_control/adapters/agent.rb +58 -0
- data/lib/hecks/quality_control/adapters/angle_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/angle_tools.rb +15 -0
- data/lib/hecks/quality_control/adapters/clearance_recorder.rb +36 -0
- data/lib/hecks/quality_control/adapters/clearance_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/clearance_tools.rb +17 -0
- data/lib/hecks/quality_control/adapters/git_pr.adapter +7 -0
- data/lib/hecks/quality_control/adapters/git_pr.rb +226 -0
- data/lib/hecks/quality_control/adapters/github_checks.adapter +9 -0
- data/lib/hecks/quality_control/adapters/github_checks.rb +83 -0
- data/lib/hecks/quality_control/adapters/github_ci_webhook.rb +56 -0
- data/lib/hecks/quality_control/adapters/github_issues.adapter +7 -0
- data/lib/hecks/quality_control/adapters/github_issues.rb +49 -0
- data/lib/hecks/quality_control/adapters/qa_tool.rb +59 -0
- data/lib/hecks/quality_control/adapters/sweep_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/sweep_tools.rb +51 -0
- data/lib/hecks/quality_control/adapters/target_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/target_tools.rb +43 -0
- data/lib/hecks/quality_control/cli/child.rb +45 -0
- data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +45 -0
- data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +248 -0
- data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +163 -0
- data/lib/hecks/quality_control/cli/qa_generated_domains.rb +450 -0
- data/lib/hecks/quality_control/cli/qa_log_bug.rb +195 -0
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +220 -0
- data/lib/hecks/quality_control/cli/qa_open_pr.rb +305 -0
- data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +154 -0
- data/lib/hecks/quality_control/cli/qa_postgres_role.rb +159 -0
- data/lib/hecks/quality_control/cli/qa_pr_check.rb +232 -0
- data/lib/hecks/quality_control/cli/qa_seed_angles.rb +142 -0
- data/lib/hecks/quality_control/cli/qa_seed_targets.rb +69 -0
- data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +280 -0
- data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +329 -0
- data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +92 -0
- data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +117 -0
- data/lib/hecks/quality_control/cli/qa_sweep/parsing.rb +175 -0
- data/lib/hecks/quality_control/cli/qa_sweep/release_mode.rb +60 -0
- data/lib/hecks/quality_control/cli/qa_sweep.rb +611 -0
- data/lib/hecks/quality_control/cli/qa_tick.rb +274 -0
- data/lib/hecks/quality_control/quality_control.bluebook +2224 -0
- data/lib/hecks/quality_control/quality_control.hecksagon +13 -0
- data/lib/hecks/quality_control/quality_control.ports.hecksagon +54 -0
- data/lib/hecks/query_ir.rb +346 -0
- data/lib/hecks/query_ir_mcp.rb +198 -0
- data/lib/hecks/release/runner/ci_publisher.rb +2 -2
- data/lib/hecks/release/runner/clean_tree.rb +42 -0
- data/lib/hecks/release/runner/console.rb +6 -0
- data/lib/hecks/release/runner/gem_publisher.rb +14 -7
- data/lib/hecks/release/runner/npm_publisher.rb +6 -36
- data/lib/hecks/release/runner/preflight.rb +26 -2
- data/lib/hecks/release/runner/published.rb +8 -26
- data/lib/hecks/release/runner/tagger.rb +30 -15
- data/lib/hecks/release/runner.rb +57 -6
- data/lib/hecks/router/namespace_installer.rb +3 -3
- data/lib/hecks/runtime/adapter_lookup.rb +124 -0
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +4 -0
- data/lib/hecks/runtime/dependency_planning.rb +20 -2
- data/lib/hecks/runtime/dispatcher.rb +53 -3
- data/lib/hecks/runtime/interpreting.rb +3 -0
- data/lib/hecks/runtime/loader.rb +65 -12
- data/lib/hecks/runtime/outbox.rb +1 -1
- data/lib/hecks/runtime/policy_interpreter.rb +5 -2
- data/lib/hecks/runtime/port_operation_interpreter.rb +2 -10
- data/lib/hecks/runtime/query_interpreter.rb +77 -0
- data/lib/hecks/runtime/reaction_outcome.rb +89 -0
- data/lib/hecks/runtime/refusal_wording.rb +23 -1
- data/lib/hecks/runtime/registry/verification.rb +163 -4
- data/lib/hecks/runtime/registry.rb +57 -3
- data/lib/hecks/runtime/remote_dispatcher.rb +31 -2
- data/lib/hecks/runtime/value/coercion.rb +23 -135
- data/lib/hecks/runtime/value/field_checks.rb +172 -0
- data/lib/hecks/runtime/value.rb +4 -1
- data/lib/hecks/runtime.rb +20 -6
- data/lib/hecks/rust_build/conformance.rb +158 -0
- data/lib/hecks/rust_build/conformance_fuzz.rb +71 -0
- data/lib/hecks/rust_build/coverage.rb +218 -0
- data/lib/hecks/rust_build/native_build.rb +124 -0
- data/lib/hecks/rust_build/project_rust/root_mod.rb +120 -0
- data/lib/hecks/rust_build/project_rust.rb +332 -0
- data/lib/hecks/rust_build/wasm.rb +105 -0
- data/lib/hecks/rust_build/wasm_browser.rb +113 -0
- data/lib/hecks/rust_build.rb +144 -0
- data/lib/hecks/storehouse.rb +16 -16
- data/lib/hecks/tenancy/bluebook/tenancy.bluebook +2 -2
- data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +1 -1
- data/lib/hecks/three_zero/forms.yml +127 -0
- data/lib/hecks/three_zero.rb +12 -0
- data/lib/hecks/tools/argument_gate_matrix.rb +316 -0
- data/lib/hecks/tools/comment_style.rb +1070 -0
- data/lib/hecks/tools/corpus_report.rb +87 -0
- data/lib/hecks/tools/deploy_recipe.rb +255 -0
- data/lib/hecks/tools/deploy_recipe_lint.rb +282 -0
- data/lib/hecks/tools/drop_implicit_append_fields.rb +95 -0
- data/lib/hecks/tools/era_projection_backfill.rb +94 -0
- data/lib/hecks/tools/era_reattest.rb +146 -0
- data/lib/hecks/tools/evolve_run.rb +189 -0
- data/lib/hecks/tools/fuzz_sweep.rb +373 -0
- data/lib/hecks/tools/heki_compaction.rb +100 -0
- data/lib/hecks/tools/hoist_local_givens.rb +246 -0
- data/lib/hecks/tools/journal_compaction.rb +89 -0
- data/lib/hecks/tools/oidc_manifests.rb +70 -0
- data/lib/hecks/tools/query_ir_run.rb +81 -0
- data/lib/hecks/tools/regeneration_run.rb +166 -0
- data/lib/hecks/tools/rust_comment_style.rb +642 -0
- data/lib/hecks/tools/sequence_script.rb +62 -0
- data/lib/hecks/tools/tail_merge.rb +117 -0
- data/lib/hecks/tools/tenant_provisioning.rb +142 -0
- data/lib/hecks/tools/translation_audit.rb +208 -0
- data/lib/hecks/tools/translation_scaffold.rb +131 -0
- data/lib/hecks/tools.rb +86 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +32 -15
- data/qa/settings.yml +66 -0
- data/rust/.cargo/config.toml +6 -0
- data/rust/.gitignore +2 -0
- data/rust/Cargo.lock +54 -0
- data/rust/Cargo.toml +61 -0
- data/rust/build/.cargo/config.toml +3 -0
- data/rust/build/.gitignore +1 -0
- data/rust/build/Cargo.lock +7 -0
- data/rust/build/Cargo.toml +19 -0
- data/rust/build/src/build_artifact.rs +47 -0
- data/rust/build/src/cargo_sync.rs +248 -0
- data/rust/build/src/json.rs +421 -0
- data/rust/build/src/lineage_pass.rs +285 -0
- data/rust/build/src/main.rs +51 -0
- data/rust/build/src/optional_pass.rs +150 -0
- data/rust/build/src/pipeline.rs +264 -0
- data/rust/build/src/reserved_names.rs +76 -0
- data/rust/build/src/resolve.rs +124 -0
- data/rust/build/src/root.rs +25 -0
- data/rust/build/src/sidecars.rs +73 -0
- data/rust/build/src/subprocess.rs +51 -0
- data/rust/build/src/tmp.rs +31 -0
- data/rust/codegen/.cargo/config.toml +3 -0
- data/rust/codegen/.gitignore +1 -0
- data/rust/codegen/Cargo.lock +7 -0
- data/rust/codegen/Cargo.toml +20 -0
- data/rust/codegen/src/attr.rs +32 -0
- data/rust/codegen/src/bridging.rs +481 -0
- data/rust/codegen/src/commands.rs +1226 -0
- data/rust/codegen/src/constraints.rs +109 -0
- data/rust/codegen/src/dependency_planning.rs +209 -0
- data/rust/codegen/src/domain_generator.rs +1288 -0
- data/rust/codegen/src/exemplar.rs +230 -0
- data/rust/codegen/src/expr_emitter.rs +87 -0
- data/rust/codegen/src/fielded.rs +192 -0
- data/rust/codegen/src/hecks_naming.rs +33 -0
- data/rust/codegen/src/json.rs +335 -0
- data/rust/codegen/src/json_codec.rs +697 -0
- data/rust/codegen/src/literal.rs +207 -0
- data/rust/codegen/src/main.rs +290 -0
- data/rust/codegen/src/manifest.rs +227 -0
- data/rust/codegen/src/mutations.rs +784 -0
- data/rust/codegen/src/naming.rs +286 -0
- data/rust/codegen/src/ports.rs +208 -0
- data/rust/codegen/src/prelude.rs +116 -0
- data/rust/codegen/src/queries.rs +786 -0
- data/rust/codegen/src/reactions.rs +532 -0
- data/rust/codegen/src/read_models.rs +697 -0
- data/rust/codegen/src/reference_specs.rs +40 -0
- data/rust/codegen/src/registry.rs +966 -0
- data/rust/codegen/src/reserved_names.rs +76 -0
- data/rust/codegen/src/shared.rs +93 -0
- data/rust/codegen/src/skip_reason.rs +25 -0
- data/rust/codegen/src/types.rs +362 -0
- data/rust/host/.cargo/config.toml +3 -0
- data/rust/host/.gitignore +1 -0
- data/rust/host/Cargo.lock +4297 -0
- data/rust/host/Cargo.toml +61 -0
- data/rust/host/HECKS_RELEASE +1 -0
- data/rust/host/rds-ca-bundle.pem +2736 -0
- data/rust/host/src/api.rs +1596 -0
- data/rust/host/src/approval.rs +652 -0
- data/rust/host/src/auth.rs +1287 -0
- data/rust/host/src/bin/expr_harness.rs +46 -0
- data/rust/host/src/bin/lineage_harness.rs +119 -0
- data/rust/host/src/bin/mint_harness.rs +135 -0
- data/rust/host/src/checkout.rs +265 -0
- data/rust/host/src/dispatch.rs +1443 -0
- data/rust/host/src/expr_json.rs +1117 -0
- data/rust/host/src/field_hints.rs +52 -0
- data/rust/host/src/ir.rs +733 -0
- data/rust/host/src/journal.rs +874 -0
- data/rust/host/src/lambda_client.rs +378 -0
- data/rust/host/src/log.rs +147 -0
- data/rust/host/src/main.rs +474 -0
- data/rust/host/src/mint.rs +1865 -0
- data/rust/host/src/payments/keystore.rs +330 -0
- data/rust/host/src/payments/tests.rs +1400 -0
- data/rust/host/src/payments.rs +767 -0
- data/rust/host/src/presentation.rs +698 -0
- data/rust/host/src/presentation_write.rs +1431 -0
- data/rust/host/src/query_step.rs +149 -0
- data/rust/host/src/rate_limit.rs +904 -0
- data/rust/host/src/reference_transform.rs +241 -0
- data/rust/host/src/reference_validate.rs +338 -0
- data/rust/host/src/resend.rs +296 -0
- data/rust/host/src/secrets.rs +106 -0
- data/rust/host/src/server.rs +663 -0
- data/rust/host/src/storage_shape.rs +175 -0
- data/rust/host/src/test_pg.rs +40 -0
- data/rust/host/src/ui_schema.rs +1351 -0
- data/rust/host/src/wasm_runner.rs +191 -0
- data/rust/host/src/web/newsletter/cooldown.rs +126 -0
- data/rust/host/src/web/newsletter/tests.rs +104 -0
- data/rust/host/src/web/newsletter.rs +310 -0
- data/rust/host/src/web/newsletter_send.rs +357 -0
- data/rust/host/src/web/registrations.rs +1505 -0
- data/rust/host/src/web.rs +2937 -0
- data/rust/lsp/.gitignore +1 -0
- data/rust/lsp/Cargo.lock +7 -0
- data/rust/lsp/Cargo.toml +16 -0
- data/rust/lsp/README.md +133 -0
- data/rust/lsp/src/diagnostics.rs +165 -0
- data/rust/lsp/src/json.rs +385 -0
- data/rust/lsp/src/main.rs +399 -0
- data/rust/lsp/src/outline.rs +177 -0
- data/rust/lsp/src/rpc.rs +67 -0
- data/rust/parser/.cargo/config.toml +5 -0
- data/rust/parser/.gitignore +1 -0
- data/rust/parser/Cargo.lock +7 -0
- data/rust/parser/Cargo.toml +21 -0
- data/rust/parser/src/build/closed_sets.rs +27 -0
- data/rust/parser/src/build/identity.rs +276 -0
- data/rust/parser/src/build/mod.rs +12 -0
- data/rust/parser/src/build/naming.rs +141 -0
- data/rust/parser/src/build/pattern_subset.rs +161 -0
- data/rust/parser/src/build/query_derive.rs +72 -0
- data/rust/parser/src/build/query_inference.rs +269 -0
- data/rust/parser/src/build/query_options.rs +100 -0
- data/rust/parser/src/build/read_model.rs +94 -0
- data/rust/parser/src/build/references.rs +32 -0
- data/rust/parser/src/canonical.rs +114 -0
- data/rust/parser/src/diag.rs +55 -0
- data/rust/parser/src/emit.rs +953 -0
- data/rust/parser/src/expr/ast_json.rs +147 -0
- data/rust/parser/src/expr/evaluator.rs +81 -0
- data/rust/parser/src/expr/mod.rs +84 -0
- data/rust/parser/src/expr/resolver.rs +497 -0
- data/rust/parser/src/ir.rs +387 -0
- data/rust/parser/src/keywords.rs +585 -0
- data/rust/parser/src/lex.rs +662 -0
- data/rust/parser/src/main.rs +252 -0
- data/rust/parser/src/parse/aggregate.rs +612 -0
- data/rust/parser/src/parse/chapter.rs +517 -0
- data/rust/parser/src/parse/command.rs +637 -0
- data/rust/parser/src/parse/domain_port.rs +127 -0
- data/rust/parser/src/parse/entity.rs +527 -0
- data/rust/parser/src/parse/file.rs +67 -0
- data/rust/parser/src/parse/hecksagon.rs +231 -0
- data/rust/parser/src/parse/lifecycle.rs +162 -0
- data/rust/parser/src/parse/mod.rs +1262 -0
- data/rust/parser/src/parse/policy.rs +87 -0
- data/rust/parser/src/parse/process_manager.rs +305 -0
- data/rust/parser/src/parse/query.rs +100 -0
- data/rust/parser/src/parse/read_model.rs +214 -0
- data/rust/parser/src/parse/value_object.rs +250 -0
- data/rust/parser/src/ruby_value.rs +422 -0
- data/rust/project/bridging.rb +257 -0
- data/rust/project/commands.rb +877 -0
- data/rust/project/constraints.rb +86 -0
- data/rust/project/dependency_planning.rb +178 -0
- data/rust/project/domain_generator.rb +980 -0
- data/rust/project/exemplar.rb +148 -0
- data/rust/project/expr_emitter.rb +72 -0
- data/rust/project/fielded.rb +141 -0
- data/rust/project/json_codec.rb +641 -0
- data/rust/project/mutations.rb +721 -0
- data/rust/project/naming.rb +205 -0
- data/rust/project/ports.rb +65 -0
- data/rust/project/queries.rb +493 -0
- data/rust/project/reactions.rb +317 -0
- data/rust/project/read_models.rb +690 -0
- data/rust/project/reference_specs.rb +27 -0
- data/rust/project/registry.rb +396 -0
- data/rust/project/skip_reason.rb +29 -0
- data/rust/project/types.rb +225 -0
- data/rust/project/write_if_changed.rb +83 -0
- data/rust/project.rb +30 -0
- data/rust/project_rust_pipeline.rb +383 -0
- data/rust/src/exemplar/commands.rs +279 -0
- data/rust/src/exemplar/constraints.rs +18 -0
- data/rust/src/exemplar/fielded.rs +280 -0
- data/rust/src/exemplar/json.rs +277 -0
- data/rust/src/exemplar/mod.rs +17 -0
- data/rust/src/exemplar/mutations.rs +80 -0
- data/rust/src/exemplar/queries.rs +33 -0
- data/rust/src/exemplar/reactions.rs +88 -0
- data/rust/src/exemplar/read_models.rs +48 -0
- data/rust/src/exemplar/registry.rs +166 -0
- data/rust/src/exemplar/types.rs +44 -0
- data/rust/src/kernel/attribute_shapes/composite.rs +88 -0
- data/rust/src/kernel/attribute_shapes/list.rs +18 -0
- data/rust/src/kernel/attribute_shapes/mod.rs +44 -0
- data/rust/src/kernel/attribute_shapes/optional.rs +9 -0
- data/rust/src/kernel/attribute_shapes/scalar.rs +39 -0
- data/rust/src/kernel/cli.rs +585 -0
- data/rust/src/kernel/dispatch.rs +1090 -0
- data/rust/src/kernel/expr.rs +458 -0
- data/rust/src/kernel/expression_operators/arithmetic.rs +128 -0
- data/rust/src/kernel/expression_operators/comparison.rs +48 -0
- data/rust/src/kernel/expression_operators/enumeration.rs +232 -0
- data/rust/src/kernel/expression_operators/logical.rs +13 -0
- data/rust/src/kernel/expression_operators/membership.rs +18 -0
- data/rust/src/kernel/expression_operators/mod.rs +68 -0
- data/rust/src/kernel/expression_operators/pattern_match.rs +73 -0
- data/rust/src/kernel/expression_operators/positional.rs +126 -0
- data/rust/src/kernel/expression_operators/presence.rs +101 -0
- data/rust/src/kernel/expression_operators/sign_test.rs +16 -0
- data/rust/src/kernel/expression_operators/sized.rs +35 -0
- data/rust/src/kernel/expression_operators/text.rs +79 -0
- data/rust/src/kernel/expression_operators/to_string.rs +18 -0
- data/rust/src/kernel/json.rs +779 -0
- data/rust/src/kernel/mod.rs +133 -0
- data/rust/src/kernel/named_query.rs +219 -0
- data/rust/src/kernel/orchestrate.rs +1810 -0
- data/rust/src/kernel/pattern.rs +307 -0
- data/rust/src/kernel/query_comparators.rs +206 -0
- data/rust/src/kernel/query_ordering.rs +103 -0
- data/rust/src/kernel/read_model.rs +914 -0
- data/rust/src/kernel/reference_lookup.rs +149 -0
- data/rust/src/kernel/repository.rs +468 -0
- data/rust/src/kernel/routing.rs +612 -0
- data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +118 -0
- data/rust/src/kernel/vocab/entity_dispatch_order.rs +114 -0
- data/rust/src/kernel/vocab/field_hint.rs +70 -0
- data/rust/src/kernel/vocab/mod.rs +20 -0
- data/rust/src/kernel/vocab/query_comparator.rs +65 -0
- data/rust/src/kernel/vocab/refusal_template.rs +1592 -0
- data/rust/src/lib.rs +8 -0
- data/rust/src/main.rs +16 -0
- data/rust/web/.gitignore +1 -0
- data/rust/web/Cargo.lock +119 -0
- data/rust/web/Cargo.toml +18 -0
- data/rust/web/src/lib.rs +8 -0
- metadata +477 -12
- data/lib/hecks/facade/cli_door.rb +0 -96
- data/lib/hecks/facade/cli_runner.rb +0 -127
- data/lib/hecks/mcp_door_scope.rb +0 -95
|
@@ -0,0 +1,1443 @@
|
|
|
1
|
+
//! Ties the journal and the sandbox together: replays history, then the
|
|
2
|
+
//! new step, persisting it only if the module accepts it.
|
|
3
|
+
|
|
4
|
+
use crate::journal;
|
|
5
|
+
use crate::journal::LineageConfig;
|
|
6
|
+
use crate::lambda_client::{self, LambdaInvoker};
|
|
7
|
+
use crate::wasm_runner;
|
|
8
|
+
use std::path::Path;
|
|
9
|
+
use tokio::sync::Mutex;
|
|
10
|
+
use tokio_postgres::Client;
|
|
11
|
+
|
|
12
|
+
pub struct Outcome {
|
|
13
|
+
pub result: serde_json::Value,
|
|
14
|
+
pub accepted: bool,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Distinguishes a module refusal from a kernel failure to run at all.
|
|
18
|
+
#[derive(Debug, PartialEq)]
|
|
19
|
+
enum KernelAnswer {
|
|
20
|
+
Accepted,
|
|
21
|
+
Refused,
|
|
22
|
+
Failed(String),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A run that never started has no `refusals` array at all, so absence of
|
|
26
|
+
// refusals doesn't by itself mean the run succeeded.
|
|
27
|
+
fn classify(result: &serde_json::Value) -> KernelAnswer {
|
|
28
|
+
if let Some(error) = result.get("error") {
|
|
29
|
+
return KernelAnswer::Failed(error.as_str().map(str::to_string).unwrap_or_else(|| error.to_string()));
|
|
30
|
+
}
|
|
31
|
+
let refused = result.get("refusals").and_then(|r| r.as_array()).is_some_and(|r| !r.is_empty());
|
|
32
|
+
if refused {
|
|
33
|
+
KernelAnswer::Refused
|
|
34
|
+
} else {
|
|
35
|
+
KernelAnswer::Accepted
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A kernel failure becomes `Err`, not a result with no instances read as
|
|
40
|
+
// an empty world.
|
|
41
|
+
fn parse_kernel_output(output: &str) -> anyhow::Result<serde_json::Value> {
|
|
42
|
+
let result: serde_json::Value = serde_json::from_str(output)?;
|
|
43
|
+
match classify(&result) {
|
|
44
|
+
KernelAnswer::Failed(message) => anyhow::bail!("the kernel failed before it could run the command: {message}"),
|
|
45
|
+
KernelAnswer::Accepted | KernelAnswer::Refused => Ok(result),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Kept as one opaque object under the journal's `args` column; the kernel
|
|
50
|
+
// unwraps `to`/`with` itself, so this needs no journal schema migration.
|
|
51
|
+
pub fn routed_invocation(to: serde_json::Value, facts: serde_json::Value) -> anyhow::Result<serde_json::Value> {
|
|
52
|
+
if !facts.is_object() {
|
|
53
|
+
anyhow::bail!("with must be an object of command facts");
|
|
54
|
+
}
|
|
55
|
+
Ok(serde_json::json!({ "to": to, "with": facts }))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// No `to` route: used only when a command creates a new aggregate, so
|
|
59
|
+
// there is no receiver yet to route to.
|
|
60
|
+
pub fn facts_invocation(facts: serde_json::Value) -> anyhow::Result<serde_json::Value> {
|
|
61
|
+
if !facts.is_object() {
|
|
62
|
+
anyhow::bail!("with must be an object of command facts");
|
|
63
|
+
}
|
|
64
|
+
Ok(serde_json::json!({ "with": facts }))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Route-aware entry point; `handle` below stays the compatibility path
|
|
68
|
+
// for callers still sending the older mixed args shape.
|
|
69
|
+
#[allow(clippy::too_many_arguments)]
|
|
70
|
+
pub async fn handle_routed(
|
|
71
|
+
client: &Mutex<Client>,
|
|
72
|
+
wasm_path: &Path,
|
|
73
|
+
verb: &str,
|
|
74
|
+
to: serde_json::Value,
|
|
75
|
+
facts: serde_json::Value,
|
|
76
|
+
role: Option<&str>,
|
|
77
|
+
config: &LineageConfig,
|
|
78
|
+
invoker: &dyn LambdaInvoker,
|
|
79
|
+
) -> anyhow::Result<Outcome> {
|
|
80
|
+
handle(client, wasm_path, verb, routed_invocation(to, facts)?, role, config, invoker).await
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Facts-only counterpart to `handle_routed`.
|
|
84
|
+
#[allow(clippy::too_many_arguments)]
|
|
85
|
+
pub async fn handle_facts(
|
|
86
|
+
client: &Mutex<Client>,
|
|
87
|
+
wasm_path: &Path,
|
|
88
|
+
verb: &str,
|
|
89
|
+
facts: serde_json::Value,
|
|
90
|
+
role: Option<&str>,
|
|
91
|
+
config: &LineageConfig,
|
|
92
|
+
invoker: &dyn LambdaInvoker,
|
|
93
|
+
) -> anyhow::Result<Outcome> {
|
|
94
|
+
handle(client, wasm_path, verb, facts_invocation(facts)?, role, config, invoker).await
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A Mutex, not a bare Arc<Client>: `handle` needs `Client::transaction`,
|
|
98
|
+
// which takes `&mut Client`. Locking also means concurrent callers on
|
|
99
|
+
// this connection can't interleave statements.
|
|
100
|
+
//
|
|
101
|
+
// `config` is required, not optional: `main.rs` already refused to boot
|
|
102
|
+
// unless the configured domain/era is provisioned and current, so by the
|
|
103
|
+
// time `handle` runs that schema is guaranteed to exist.
|
|
104
|
+
//
|
|
105
|
+
// `role` is optional, matching `Adapters::Lambda::Client#dispatch`'s own
|
|
106
|
+
// `role: nil` default: a step with no role looks the same on the wire
|
|
107
|
+
// whether or not a caller is bound.
|
|
108
|
+
pub async fn handle(
|
|
109
|
+
client: &Mutex<Client>,
|
|
110
|
+
wasm_path: &Path,
|
|
111
|
+
verb: &str,
|
|
112
|
+
args: serde_json::Value,
|
|
113
|
+
role: Option<&str>,
|
|
114
|
+
config: &LineageConfig,
|
|
115
|
+
invoker: &dyn LambdaInvoker,
|
|
116
|
+
) -> anyhow::Result<Outcome> {
|
|
117
|
+
let mut guard = client.lock().await;
|
|
118
|
+
let txn = guard.transaction().await?;
|
|
119
|
+
|
|
120
|
+
// Locks the whole read-then-append sequence, not just the INSERT:
|
|
121
|
+
// two concurrent invocations could otherwise both rehydrate against
|
|
122
|
+
// the same prior steps and both pass a uniqueness check the domain
|
|
123
|
+
// thinks it's enforcing. Domain-scoped so unrelated domains sharing
|
|
124
|
+
// one Postgres instance don't serialize against each other.
|
|
125
|
+
txn.execute(
|
|
126
|
+
"SELECT pg_advisory_xact_lock(hashtext('hecks_lambda_journal.' || $1::text))",
|
|
127
|
+
&[&config.domain],
|
|
128
|
+
)
|
|
129
|
+
.await?;
|
|
130
|
+
|
|
131
|
+
// Reads the last snapshot plus only the journal rows after it,
|
|
132
|
+
// instead of replaying the whole history every time. `None` covers
|
|
133
|
+
// a brand-new domain and one with pre-snapshot history alike; both
|
|
134
|
+
// fall back to one full replay, which then leaves a snapshot behind.
|
|
135
|
+
let snapshot = journal::load_snapshot(&txn).await?;
|
|
136
|
+
// `sagas_backfilled` is a latch, not a live check of whether
|
|
137
|
+
// `hecks_lambda_sagas` is empty (empty is the ordinary state, not a
|
|
138
|
+
// signal). Only matters for a domain with real history predating
|
|
139
|
+
// saga durability, where the snapshot cache would otherwise never
|
|
140
|
+
// re-derive sagas from the journal.
|
|
141
|
+
let needs_saga_backfill = snapshot.as_ref().is_some_and(|s| !s.sagas_backfilled);
|
|
142
|
+
let mut steps = match &snapshot {
|
|
143
|
+
Some(s) if !needs_saga_backfill => journal::load_steps_after(&txn, s.ordinal).await?,
|
|
144
|
+
_ => journal::load_steps(&txn).await?,
|
|
145
|
+
};
|
|
146
|
+
// Role goes only on this outermost step; replayed history already
|
|
147
|
+
// carries whatever role it was dispatched with. Built as a mutable
|
|
148
|
+
// step, not inlined into the `json!` literal, so a roleless call
|
|
149
|
+
// still omits the `"role"` key entirely rather than sending null.
|
|
150
|
+
let mut step = serde_json::json!({ "verb": verb, "args": args.clone() });
|
|
151
|
+
if let Some(role) = role {
|
|
152
|
+
step["role"] = serde_json::Value::String(role.to_string());
|
|
153
|
+
}
|
|
154
|
+
// Stamped only on this live step; replayed history already carries
|
|
155
|
+
// its own real timestamp, and re-stamping today's time onto a
|
|
156
|
+
// replay would be wrong, not just redundant.
|
|
157
|
+
step["occurred_at"] = serde_json::Value::String(crate::auth::httpdate_now());
|
|
158
|
+
steps.push(step);
|
|
159
|
+
// Must match `steps`' own choice above: a full replay needs an empty
|
|
160
|
+
// seed, or the kernel replays into a world that already has every
|
|
161
|
+
// record being created again, refusing each as a false AlreadyExists.
|
|
162
|
+
let seed = if needs_saga_backfill {
|
|
163
|
+
serde_json::json!({})
|
|
164
|
+
} else {
|
|
165
|
+
snapshot.as_ref().map(|s| s.seed.clone()).unwrap_or_else(|| serde_json::json!({}))
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Read inside the same advisory-locked transaction: the lock covers
|
|
169
|
+
// saga state too, not just the aggregate journal/snapshot.
|
|
170
|
+
let saga_rows = journal::load_sagas(&txn).await?;
|
|
171
|
+
let sagas_seed = serde_json::json!(saga_rows
|
|
172
|
+
.iter()
|
|
173
|
+
.map(|r| serde_json::json!({
|
|
174
|
+
"process_manager": r.process_manager,
|
|
175
|
+
"correlation": r.correlation,
|
|
176
|
+
"state": r.state,
|
|
177
|
+
"memory": r.memory,
|
|
178
|
+
"completed_compensations": r.completed_compensations,
|
|
179
|
+
}))
|
|
180
|
+
.collect::<Vec<_>>());
|
|
181
|
+
|
|
182
|
+
let input = serde_json::json!({ "seed": seed, "steps": steps, "sagas": sagas_seed }).to_string();
|
|
183
|
+
// `wasm_runner::run` is sync, and wasmtime-wasi's sync bridge spins up
|
|
184
|
+
// its own tokio runtime internally — fatal on a thread already
|
|
185
|
+
// driving one. `spawn_blocking` moves it off this async runtime.
|
|
186
|
+
let owned_wasm_path = wasm_path.to_path_buf();
|
|
187
|
+
let output =
|
|
188
|
+
tokio::task::spawn_blocking(move || wasm_runner::run(&owned_wasm_path, &input)).await??;
|
|
189
|
+
// A kernel failure returns here, before anything is written: dropping `txn`
|
|
190
|
+
// rolls it back, so no journal row, snapshot, saga or mutation is saved.
|
|
191
|
+
let mut result = parse_kernel_output(&output)?;
|
|
192
|
+
|
|
193
|
+
// See journal.rs's own header: every prior step in this replay
|
|
194
|
+
// already succeeded once, deterministically, so the only step that
|
|
195
|
+
// can legitimately show up in `refusals` is the new one just
|
|
196
|
+
// appended last.
|
|
197
|
+
let accepted = classify(&result) == KernelAnswer::Accepted;
|
|
198
|
+
|
|
199
|
+
if accepted {
|
|
200
|
+
let ordinal = journal::append(&txn, verb, &args).await?;
|
|
201
|
+
|
|
202
|
+
// The kernel's "instances" output is already the exact seed
|
|
203
|
+
// shape, so nothing here re-derives or filters it.
|
|
204
|
+
let new_seed = result.get("instances").cloned().unwrap_or_else(|| serde_json::json!({}));
|
|
205
|
+
journal::save_snapshot(&txn, ordinal, &new_seed).await?;
|
|
206
|
+
|
|
207
|
+
// Persisted in the same transaction, so a saga checkpoint can
|
|
208
|
+
// never commit independently of the command that produced it.
|
|
209
|
+
// Reconciled against `saga_rows` (pre-run state): present now
|
|
210
|
+
// is upserted, present before but absent now is deleted.
|
|
211
|
+
if let Some(saga_snapshot) = result.get("saga_snapshot").and_then(|v| v.as_array()) {
|
|
212
|
+
let mut still_present: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
|
|
213
|
+
for entry in saga_snapshot {
|
|
214
|
+
let process_manager = entry
|
|
215
|
+
.get("process_manager")
|
|
216
|
+
.and_then(|v| v.as_str())
|
|
217
|
+
.ok_or_else(|| anyhow::anyhow!("saga_snapshot entry missing \"process_manager\": {entry}"))?;
|
|
218
|
+
let correlation = entry
|
|
219
|
+
.get("correlation")
|
|
220
|
+
.and_then(|v| v.as_str())
|
|
221
|
+
.ok_or_else(|| anyhow::anyhow!("saga_snapshot entry missing \"correlation\": {entry}"))?;
|
|
222
|
+
let state = entry
|
|
223
|
+
.get("state")
|
|
224
|
+
.and_then(|v| v.as_str())
|
|
225
|
+
.ok_or_else(|| anyhow::anyhow!("saga_snapshot entry missing \"state\": {entry}"))?;
|
|
226
|
+
let memory = entry.get("memory").cloned().unwrap_or_else(|| serde_json::json!({}));
|
|
227
|
+
let completed_compensations = entry.get("completed_compensations").cloned().unwrap_or_else(|| serde_json::json!([]));
|
|
228
|
+
|
|
229
|
+
journal::save_saga(&txn, process_manager, correlation, state, &memory, &completed_compensations).await?;
|
|
230
|
+
still_present.insert((process_manager.to_string(), correlation.to_string()));
|
|
231
|
+
}
|
|
232
|
+
for row in &saga_rows {
|
|
233
|
+
let key = (row.process_manager.clone(), row.correlation.clone());
|
|
234
|
+
if !still_present.contains(&key) {
|
|
235
|
+
journal::delete_saga(&txn, &row.process_manager, &row.correlation).await?;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// One entry per step in `steps`, so the last entry is this
|
|
241
|
+
// call's own new step. Skipped when `config.era` is `None`: a
|
|
242
|
+
// domain with nothing lineage-capable bound never provisions a
|
|
243
|
+
// head-snapshot table to write into (ADR 0034).
|
|
244
|
+
let step_mutations = if config.era.is_some() {
|
|
245
|
+
result
|
|
246
|
+
.get("mutations")
|
|
247
|
+
.and_then(|m| m.as_array())
|
|
248
|
+
.and_then(|steps| steps.last())
|
|
249
|
+
.and_then(|last| last.as_array())
|
|
250
|
+
.cloned()
|
|
251
|
+
.unwrap_or_default()
|
|
252
|
+
} else {
|
|
253
|
+
Vec::new()
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
for mutation in &step_mutations {
|
|
257
|
+
let aggregate = mutation
|
|
258
|
+
.get("aggregate")
|
|
259
|
+
.and_then(|v| v.as_str())
|
|
260
|
+
.ok_or_else(|| anyhow::anyhow!("mutation record missing \"aggregate\": {mutation}"))?;
|
|
261
|
+
// `era.is_some()` means the lineage subsystem exists, not that
|
|
262
|
+
// this aggregate has a mirror: `mint` only provisions head
|
|
263
|
+
// snapshots for the capable set, so mirroring anything else
|
|
264
|
+
// would upsert into a relation nobody created.
|
|
265
|
+
if !config.mirrors(aggregate) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
let id = mutation
|
|
269
|
+
.get("id")
|
|
270
|
+
.and_then(|v| v.as_str())
|
|
271
|
+
.ok_or_else(|| anyhow::anyhow!("mutation record missing \"id\": {mutation}"))?;
|
|
272
|
+
let operation = mutation
|
|
273
|
+
.get("operation")
|
|
274
|
+
.and_then(|v| v.as_str())
|
|
275
|
+
.ok_or_else(|| anyhow::anyhow!("mutation record missing \"operation\": {mutation}"))?;
|
|
276
|
+
let state = mutation
|
|
277
|
+
.get("state")
|
|
278
|
+
.ok_or_else(|| anyhow::anyhow!("mutation record missing \"state\": {mutation}"))?;
|
|
279
|
+
|
|
280
|
+
journal::append_lineage_mutation(
|
|
281
|
+
&txn,
|
|
282
|
+
config,
|
|
283
|
+
&journal::Mutation { aggregate, id, operation, state },
|
|
284
|
+
)
|
|
285
|
+
.await?;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Only this step's own matches, same reason as `step_mutations`
|
|
290
|
+
// above: replaying a prior step's reaction would re-invoke a sibling
|
|
291
|
+
// Lambda on every later replay. Captured before commit, delivered after.
|
|
292
|
+
let pending_cross_domain = result
|
|
293
|
+
.get("cross_domain_reactions")
|
|
294
|
+
.and_then(|r| r.as_array())
|
|
295
|
+
.and_then(|steps| steps.last())
|
|
296
|
+
.and_then(|last| last.as_array())
|
|
297
|
+
.cloned()
|
|
298
|
+
.unwrap_or_default();
|
|
299
|
+
|
|
300
|
+
// Commits (and releases the advisory lock) whether or not anything
|
|
301
|
+
// was appended — a refused command still needs the lock released
|
|
302
|
+
// for the next invocation to proceed.
|
|
303
|
+
txn.commit().await?;
|
|
304
|
+
|
|
305
|
+
// Delivered after commit, deliberately: the local command already
|
|
306
|
+
// succeeded and is durable, so a cross-Lambda notification is
|
|
307
|
+
// best-effort, not a precondition of it. A fault that survives every
|
|
308
|
+
// retry still fails this call via `?`, but never unwinds an
|
|
309
|
+
// already-committed transaction.
|
|
310
|
+
// Every reaction gets its own attempt even after an earlier fails:
|
|
311
|
+
// returning early would silently drop the rest, undelivered and
|
|
312
|
+
// never dead-lettered (`SafeDepositBox.Surrender` fires two from one
|
|
313
|
+
// command). `first_failure` remembers only the first error without
|
|
314
|
+
// cutting the loop short.
|
|
315
|
+
let mut cross_domain_deliveries = Vec::new();
|
|
316
|
+
let mut first_failure: Option<anyhow::Error> = None;
|
|
317
|
+
for reaction in &pending_cross_domain {
|
|
318
|
+
match lambda_client::deliver_with_retry(invoker, reaction).await {
|
|
319
|
+
Ok(record) => {
|
|
320
|
+
if let Some(response) = result.as_object_mut() {
|
|
321
|
+
if let Some(reactions) = response.get_mut("reactions").and_then(|r| r.as_array_mut()) {
|
|
322
|
+
reactions.push(serde_json::json!({
|
|
323
|
+
"policy": record.policy, "on": reaction.get("on").and_then(|v| v.as_str()).unwrap_or_default(),
|
|
324
|
+
"trigger": record.target_verb, "delivered": record.delivered, "reason": record.reason,
|
|
325
|
+
}));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
cross_domain_deliveries.push(record.to_json());
|
|
329
|
+
}
|
|
330
|
+
Err(failure) => {
|
|
331
|
+
let error_text = format!("{:#}", failure.error);
|
|
332
|
+
crate::log::error("cross_domain_delivery_failed", serde_json::json!({
|
|
333
|
+
"policy": failure.policy, "target_domain": failure.target_domain,
|
|
334
|
+
"target_verb": failure.target_verb, "attempts": failure.attempts, "error": error_text,
|
|
335
|
+
}));
|
|
336
|
+
journal::record_dead_letter(
|
|
337
|
+
&*guard,
|
|
338
|
+
&failure.policy,
|
|
339
|
+
&failure.target_domain,
|
|
340
|
+
&failure.target_verb,
|
|
341
|
+
&failure.payload,
|
|
342
|
+
&error_text,
|
|
343
|
+
failure.attempts as i32,
|
|
344
|
+
)
|
|
345
|
+
.await?;
|
|
346
|
+
if first_failure.is_none() {
|
|
347
|
+
first_failure = Some(failure.error);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if let Some(error) = first_failure {
|
|
353
|
+
return Err(error);
|
|
354
|
+
}
|
|
355
|
+
if let Some(response) = result.as_object_mut() {
|
|
356
|
+
response.insert("cross_domain_deliveries".to_string(), serde_json::Value::Array(cross_domain_deliveries));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
log_command(verb, role, accepted, &result);
|
|
360
|
+
Ok(Outcome { result, accepted })
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// One line per dispatched command: what was asked (verb, role — never
|
|
364
|
+
// the facts) and what the domain answered.
|
|
365
|
+
fn log_command(verb: &str, role: Option<&str>, accepted: bool, result: &serde_json::Value) {
|
|
366
|
+
let fields = command_fields(verb, role, accepted, result);
|
|
367
|
+
if accepted {
|
|
368
|
+
crate::log::info("command", fields);
|
|
369
|
+
} else {
|
|
370
|
+
crate::log::error("command", fields);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
fn command_fields(verb: &str, role: Option<&str>, accepted: bool, result: &serde_json::Value) -> serde_json::Value {
|
|
375
|
+
let events: Vec<serde_json::Value> = result
|
|
376
|
+
.get("events")
|
|
377
|
+
.and_then(|v| v.as_array())
|
|
378
|
+
.map(|items| items.iter().filter_map(|item| item.get("name").cloned()).collect())
|
|
379
|
+
.unwrap_or_default();
|
|
380
|
+
serde_json::json!({
|
|
381
|
+
"verb": verb, "role": role, "accepted": accepted, "events": events,
|
|
382
|
+
"refusals": result.get("refusals").cloned().unwrap_or_else(|| serde_json::json!([])),
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Read-only: the same seed path `handle` uses, minus the new step and
|
|
387
|
+
// any write. No advisory lock needed since nothing here writes.
|
|
388
|
+
// Returns the whole kernel result verbatim, not just `instances`, so a
|
|
389
|
+
// caller can tell an empty `refusals` from one that was never checked.
|
|
390
|
+
pub async fn read(client: &Mutex<Client>, wasm_path: &Path) -> anyhow::Result<serde_json::Value> {
|
|
391
|
+
let guard = client.lock().await;
|
|
392
|
+
let snapshot = journal::load_snapshot(&*guard).await?;
|
|
393
|
+
|
|
394
|
+
if let Some(s) = &snapshot {
|
|
395
|
+
let steps_since = journal::load_steps_after(&*guard, s.ordinal).await?;
|
|
396
|
+
drop(guard);
|
|
397
|
+
|
|
398
|
+
if steps_since.is_empty() {
|
|
399
|
+
// No wasm invocation needed: the snapshot's seed already is
|
|
400
|
+
// this domain's current "instances" output. Empty
|
|
401
|
+
// events/refusals is correct too — a read reports current
|
|
402
|
+
// state, never event/refusal history.
|
|
403
|
+
return Ok(serde_json::json!({ "instances": s.seed, "events": [], "refusals": [] }));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Self-healing: if steps ever go unaccounted for after the last
|
|
407
|
+
// snapshot, seeding from it and replaying just the gap is
|
|
408
|
+
// still correct and cheap.
|
|
409
|
+
let input = serde_json::json!({ "seed": &s.seed, "steps": steps_since }).to_string();
|
|
410
|
+
let owned_wasm_path = wasm_path.to_path_buf();
|
|
411
|
+
let output =
|
|
412
|
+
tokio::task::spawn_blocking(move || wasm_runner::run(&owned_wasm_path, &input)).await??;
|
|
413
|
+
return parse_kernel_output(&output);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// No snapshot: either a brand-new domain, or one with history from
|
|
417
|
+
// before this snapshot cache existed — either way, one full replay
|
|
418
|
+
// catches it up.
|
|
419
|
+
let steps = journal::load_steps(&*guard).await?;
|
|
420
|
+
drop(guard);
|
|
421
|
+
let input = serde_json::json!({ "steps": steps }).to_string();
|
|
422
|
+
let owned_wasm_path = wasm_path.to_path_buf();
|
|
423
|
+
let output =
|
|
424
|
+
tokio::task::spawn_blocking(move || wasm_runner::run(&owned_wasm_path, &input)).await??;
|
|
425
|
+
parse_kernel_output(&output)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// A declared query, answered against current state. Seeds from `read`
|
|
429
|
+
// rather than replaying history itself, so it sees exactly the state a
|
|
430
|
+
// read would report and pays at most one wasm invocation.
|
|
431
|
+
pub async fn query(
|
|
432
|
+
client: &Mutex<Client>,
|
|
433
|
+
wasm_path: &Path,
|
|
434
|
+
question: &str,
|
|
435
|
+
args: serde_json::Value,
|
|
436
|
+
) -> anyhow::Result<serde_json::Value> {
|
|
437
|
+
let state = read(client, wasm_path).await?;
|
|
438
|
+
let seed = state.get("instances").cloned().unwrap_or_else(|| serde_json::json!({}));
|
|
439
|
+
let input = serde_json::json!({ "seed": seed, "steps": [{ "query": question, "args": args }] }).to_string();
|
|
440
|
+
let owned_wasm_path = wasm_path.to_path_buf();
|
|
441
|
+
let output = tokio::task::spawn_blocking(move || wasm_runner::run(&owned_wasm_path, &input)).await??;
|
|
442
|
+
parse_kernel_output(&output)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
#[cfg(test)]
|
|
446
|
+
pub(crate) mod tests {
|
|
447
|
+
|
|
448
|
+
#[test]
|
|
449
|
+
fn a_command_log_line_names_the_verb_events_and_refusals_but_never_the_facts() {
|
|
450
|
+
let result = serde_json::json!({
|
|
451
|
+
"events": [{"name": "Registered", "data": {"email": "a@b.c"}}],
|
|
452
|
+
"refusals": [{"verb": "X.Y", "error": "already exists", "kind": "AlreadyExists"}],
|
|
453
|
+
});
|
|
454
|
+
let fields = command_fields("Register", Some("admin"), false, &result);
|
|
455
|
+
assert_eq!(fields["verb"], "Register");
|
|
456
|
+
assert_eq!(fields["role"], "admin");
|
|
457
|
+
assert_eq!(fields["accepted"], false);
|
|
458
|
+
assert_eq!(fields["events"], serde_json::json!(["Registered"]));
|
|
459
|
+
assert_eq!(fields["refusals"][0]["kind"], "AlreadyExists");
|
|
460
|
+
assert!(!fields.to_string().contains("a@b.c"));
|
|
461
|
+
}
|
|
462
|
+
use super::*;
|
|
463
|
+
use tokio_postgres::NoTls;
|
|
464
|
+
|
|
465
|
+
// A throwaway Postgres database per test, uniquely named so
|
|
466
|
+
// `cargo test`'s default parallelism doesn't race two tests against
|
|
467
|
+
// the same journal table.
|
|
468
|
+
pub(crate) async fn scratch_db(name: &str) -> Mutex<Client> {
|
|
469
|
+
let (admin, conn) = tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls)
|
|
470
|
+
.await
|
|
471
|
+
.expect("connect to postgres");
|
|
472
|
+
tokio::spawn(async move {
|
|
473
|
+
let _ = conn.await;
|
|
474
|
+
});
|
|
475
|
+
admin
|
|
476
|
+
.batch_execute(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)"))
|
|
477
|
+
.await
|
|
478
|
+
.unwrap();
|
|
479
|
+
admin
|
|
480
|
+
.batch_execute(&format!("CREATE DATABASE {name}"))
|
|
481
|
+
.await
|
|
482
|
+
.unwrap();
|
|
483
|
+
|
|
484
|
+
let (client, conn) =
|
|
485
|
+
tokio_postgres::connect(&crate::test_pg::conninfo(&name), NoTls)
|
|
486
|
+
.await
|
|
487
|
+
.expect("connect to scratch db");
|
|
488
|
+
tokio::spawn(async move {
|
|
489
|
+
let _ = conn.await;
|
|
490
|
+
});
|
|
491
|
+
journal::ensure_schema(&client).await.unwrap();
|
|
492
|
+
Mutex::new(client)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Not Ruby's real provisioning — just enough structure for
|
|
496
|
+
// `journal::current_era` and `append_lineage_mutation` to succeed,
|
|
497
|
+
// without reproducing Ruby's full DDL.
|
|
498
|
+
pub(crate) async fn provision_lineage(client: &Client, domain: &str, era: i32, aggregate_storage_names: &[&str]) {
|
|
499
|
+
// `int`, matching Ruby's real DDL: tokio_postgres requires the
|
|
500
|
+
// Rust and Postgres types to match, not just be compatible.
|
|
501
|
+
client
|
|
502
|
+
.batch_execute("CREATE TABLE IF NOT EXISTS hecks_eras (domain text, ordinal int, held_text text)")
|
|
503
|
+
.await
|
|
504
|
+
.unwrap();
|
|
505
|
+
client
|
|
506
|
+
.execute(
|
|
507
|
+
"INSERT INTO hecks_eras (domain, ordinal, held_text) VALUES ($1, $2, 'test')",
|
|
508
|
+
&[&domain, &era],
|
|
509
|
+
)
|
|
510
|
+
.await
|
|
511
|
+
.unwrap();
|
|
512
|
+
|
|
513
|
+
let journal_table = format!("hecks_journal_{}", journal::snake(domain));
|
|
514
|
+
client
|
|
515
|
+
.batch_execute(&format!(
|
|
516
|
+
"CREATE TABLE IF NOT EXISTS \"{journal_table}\" (
|
|
517
|
+
ordinal bigserial PRIMARY KEY,
|
|
518
|
+
era int NOT NULL,
|
|
519
|
+
aggregate text NOT NULL,
|
|
520
|
+
aggregate_id text NOT NULL,
|
|
521
|
+
operation text NOT NULL,
|
|
522
|
+
state jsonb
|
|
523
|
+
)"
|
|
524
|
+
))
|
|
525
|
+
.await
|
|
526
|
+
.unwrap();
|
|
527
|
+
|
|
528
|
+
for name in aggregate_storage_names {
|
|
529
|
+
// Domain-qualified (ADR 0059), matching what a real write
|
|
530
|
+
// targets.
|
|
531
|
+
let snapshot_table = journal::qualified_name(domain, &format!("{}_head_snapshot_{era}", journal::snake(name)));
|
|
532
|
+
client
|
|
533
|
+
.batch_execute(&format!(
|
|
534
|
+
"CREATE TABLE IF NOT EXISTS \"{snapshot_table}\" (id text PRIMARY KEY, ordinal bigint NOT NULL, \
|
|
535
|
+
operation text NOT NULL DEFAULT 'save', state jsonb)"
|
|
536
|
+
))
|
|
537
|
+
.await
|
|
538
|
+
.unwrap();
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Pins a real outage: `era.is_some()` (lineage exists) and "this
|
|
543
|
+
// aggregate has a mirror" are different questions. Under ADR 0034 an
|
|
544
|
+
// aggregate outside the declared capable set has no head snapshot to
|
|
545
|
+
// upsert into.
|
|
546
|
+
#[tokio::test]
|
|
547
|
+
async fn a_domain_whose_ir_declares_nothing_lineage_capable_mirrors_nothing_and_still_writes() {
|
|
548
|
+
let client = scratch_db("rust_host_mirrors_nothing").await;
|
|
549
|
+
{
|
|
550
|
+
let guard = client.lock().await;
|
|
551
|
+
// No head snapshot for Customer, as `mint` leaves an empty
|
|
552
|
+
// capable set.
|
|
553
|
+
guard.batch_execute("CREATE TABLE IF NOT EXISTS hecks_eras (domain text, ordinal int, held_text text)").await.unwrap();
|
|
554
|
+
guard.execute("INSERT INTO hecks_eras (domain, ordinal, held_text) VALUES ('Banking', 1, 'test')", &[]).await.unwrap();
|
|
555
|
+
guard
|
|
556
|
+
.batch_execute(
|
|
557
|
+
"CREATE TABLE IF NOT EXISTS hecks_journal_banking (ordinal bigserial PRIMARY KEY, era int NOT NULL, \
|
|
558
|
+
aggregate text NOT NULL, aggregate_id text NOT NULL, operation text NOT NULL, state jsonb)",
|
|
559
|
+
)
|
|
560
|
+
.await
|
|
561
|
+
.unwrap();
|
|
562
|
+
}
|
|
563
|
+
let wasm = wasm_path();
|
|
564
|
+
let invoker = crate::lambda_client::NeverInvoker;
|
|
565
|
+
let open = register("CUST-1");
|
|
566
|
+
|
|
567
|
+
// Mirroring everything is the outage: no head snapshot exists to
|
|
568
|
+
// upsert into.
|
|
569
|
+
let everything = LineageConfig { domain: "Banking".to_string(), era: Some(1), mirrored: None };
|
|
570
|
+
let refused = handle_facts(&client, &wasm, "Banking::Customer.Register", open.clone(), None, &everything, &invoker).await;
|
|
571
|
+
let message = match refused {
|
|
572
|
+
Ok(_) => panic!("expected the missing-relation failure"),
|
|
573
|
+
Err(e) => format!("{e:#}"),
|
|
574
|
+
};
|
|
575
|
+
assert!(message.contains("banking_customer_head_snapshot_1"), "names the relation it could not write: {message}");
|
|
576
|
+
assert!(message.contains("does not exist"), "carries the database's own words: {message}");
|
|
577
|
+
assert!(message.contains("Banking::Customer"), "names the record: {message}");
|
|
578
|
+
|
|
579
|
+
// Mirroring what the IR actually declares — nothing — writes
|
|
580
|
+
// cleanly, into this crate's own journal.
|
|
581
|
+
let declared = LineageConfig {
|
|
582
|
+
domain: "Banking".to_string(),
|
|
583
|
+
era: Some(1),
|
|
584
|
+
mirrored: Some(std::collections::BTreeSet::new()),
|
|
585
|
+
};
|
|
586
|
+
let outcome = handle_facts(&client, &wasm, "Banking::Customer.Register", open, None, &declared, &invoker)
|
|
587
|
+
.await
|
|
588
|
+
.expect("writes");
|
|
589
|
+
assert!(outcome.accepted, "{}", outcome.result);
|
|
590
|
+
|
|
591
|
+
let guard = client.lock().await;
|
|
592
|
+
let journalled: i64 = guard.query_one("SELECT count(*) FROM hecks_lambda_journal", &[]).await.unwrap().get(0);
|
|
593
|
+
assert_eq!(journalled, 1, "durable in this crate's own journal, which is what `read` replays");
|
|
594
|
+
let mirror: Option<String> =
|
|
595
|
+
guard.query_one("SELECT to_regclass('banking_customer_head_snapshot_1')::text", &[]).await.unwrap().get(0);
|
|
596
|
+
assert!(mirror.is_none(), "and no mirror was invented for an aggregate the IR never called capable");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// The other half: an aggregate the IR does call capable is still
|
|
600
|
+
// mirrored.
|
|
601
|
+
#[tokio::test]
|
|
602
|
+
async fn an_aggregate_the_ir_declares_capable_is_still_mirrored() {
|
|
603
|
+
let client = scratch_db("rust_host_mirrors_the_capable_one").await;
|
|
604
|
+
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
605
|
+
let config = LineageConfig {
|
|
606
|
+
domain: "Banking".to_string(),
|
|
607
|
+
era: Some(1),
|
|
608
|
+
mirrored: Some(["Banking::Customer".to_string()].into_iter().collect()),
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
handle_facts(
|
|
612
|
+
&client,
|
|
613
|
+
&wasm_path(),
|
|
614
|
+
"Banking::Customer.Register",
|
|
615
|
+
register("CUST-2"),
|
|
616
|
+
None,
|
|
617
|
+
&config,
|
|
618
|
+
&crate::lambda_client::NeverInvoker,
|
|
619
|
+
)
|
|
620
|
+
.await
|
|
621
|
+
.expect("writes");
|
|
622
|
+
|
|
623
|
+
let guard = client.lock().await;
|
|
624
|
+
let mirrored: i64 =
|
|
625
|
+
guard.query_one("SELECT count(*) FROM banking_customer_head_snapshot_1", &[]).await.unwrap().get(0);
|
|
626
|
+
assert_eq!(mirrored, 1);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
fn test_config(domain: &str, era: i32) -> LineageConfig {
|
|
630
|
+
LineageConfig { domain: domain.to_string(), era: Some(era), mirrored: None }
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
pub(crate) fn wasm_path() -> std::path::PathBuf {
|
|
634
|
+
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist/banking.wasm")
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
fn register(reference: &str) -> serde_json::Value {
|
|
638
|
+
serde_json::json!({
|
|
639
|
+
"reference": { "value": reference },
|
|
640
|
+
"name": { "given": "Ada", "family": "Lovelace" },
|
|
641
|
+
"email": { "address": "ada@example.com" }
|
|
642
|
+
})
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// `query` end to end through the real compiled kernel: a declared
|
|
646
|
+
// query answered against whatever state the journal currently holds.
|
|
647
|
+
#[tokio::test]
|
|
648
|
+
async fn a_declared_query_answers_against_current_state() {
|
|
649
|
+
let client = scratch_db("rust_host_dispatch_test_query").await;
|
|
650
|
+
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
651
|
+
let config = test_config("Banking", 1);
|
|
652
|
+
|
|
653
|
+
handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0100"), None, &config, &lambda_client::NeverInvoker)
|
|
654
|
+
.await
|
|
655
|
+
.unwrap()
|
|
656
|
+
.accepted
|
|
657
|
+
.then_some(())
|
|
658
|
+
.expect("registering a customer should succeed");
|
|
659
|
+
|
|
660
|
+
// `Customer.Suspended` is `where status == "suspended"` — a
|
|
661
|
+
// freshly registered customer is active, so the query has to
|
|
662
|
+
// answer with a real, empty row set, not everything.
|
|
663
|
+
let before = query(&client, &wasm_path(), "Banking::Customer.Suspended", serde_json::json!({})).await.unwrap();
|
|
664
|
+
assert_eq!(before["queries"][0]["query"], "Banking::Customer.Suspended");
|
|
665
|
+
assert_eq!(before["queries"][0]["rows"].as_array().expect("rows").len(), 0);
|
|
666
|
+
|
|
667
|
+
handle(
|
|
668
|
+
&client,
|
|
669
|
+
&wasm_path(),
|
|
670
|
+
"Banking::Customer.Suspend",
|
|
671
|
+
serde_json::json!({ "reference": "CUST-0100", "standing": { "value": "watch" } }),
|
|
672
|
+
None,
|
|
673
|
+
&config,
|
|
674
|
+
&lambda_client::NeverInvoker,
|
|
675
|
+
)
|
|
676
|
+
.await
|
|
677
|
+
.unwrap()
|
|
678
|
+
.accepted
|
|
679
|
+
.then_some(())
|
|
680
|
+
.expect("suspending a customer should succeed");
|
|
681
|
+
|
|
682
|
+
let after = query(&client, &wasm_path(), "Banking::Customer.Suspended", serde_json::json!({})).await.unwrap();
|
|
683
|
+
let rows = after["queries"][0]["rows"].as_array().expect("rows");
|
|
684
|
+
assert_eq!(rows.len(), 1, "the suspended customer should be the one row: {after}");
|
|
685
|
+
assert_eq!(rows[0]["id"], "CUST-0100");
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
#[tokio::test]
|
|
689
|
+
async fn accepts_and_persists_a_first_command() {
|
|
690
|
+
let client = scratch_db("rust_host_dispatch_test_1").await;
|
|
691
|
+
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
692
|
+
|
|
693
|
+
let outcome = handle(
|
|
694
|
+
&client,
|
|
695
|
+
&wasm_path(),
|
|
696
|
+
"Banking::Customer.Register",
|
|
697
|
+
register("CUST-0001"),
|
|
698
|
+
None,
|
|
699
|
+
&test_config("Banking", 1),
|
|
700
|
+
&lambda_client::NeverInvoker,
|
|
701
|
+
)
|
|
702
|
+
.await
|
|
703
|
+
.unwrap();
|
|
704
|
+
|
|
705
|
+
assert!(outcome.accepted);
|
|
706
|
+
assert_eq!(outcome.result["refusals"].as_array().unwrap().len(), 0);
|
|
707
|
+
let guard = client.lock().await;
|
|
708
|
+
let steps = journal::load_steps(&*guard).await.unwrap();
|
|
709
|
+
assert_eq!(steps.len(), 1);
|
|
710
|
+
|
|
711
|
+
// The lineage write itself: a row landed in the era-tagged,
|
|
712
|
+
// storage-name-keyed journal shape, not just the flat log above.
|
|
713
|
+
let journal_rows = guard
|
|
714
|
+
.query("SELECT era, aggregate, aggregate_id, operation FROM hecks_journal_banking", &[])
|
|
715
|
+
.await
|
|
716
|
+
.unwrap();
|
|
717
|
+
assert_eq!(journal_rows.len(), 1);
|
|
718
|
+
let row = &journal_rows[0];
|
|
719
|
+
let era: i32 = row.get(0);
|
|
720
|
+
let aggregate: String = row.get(1);
|
|
721
|
+
let aggregate_id: String = row.get(2);
|
|
722
|
+
let operation: String = row.get(3);
|
|
723
|
+
assert_eq!((era, aggregate.as_str(), aggregate_id.as_str(), operation.as_str()), (1, "customer", "CUST-0001", "save"));
|
|
724
|
+
|
|
725
|
+
// domain-qualified (docs/decisions/0059) — snake("Banking") == "banking".
|
|
726
|
+
let snapshot_rows = guard
|
|
727
|
+
.query("SELECT id FROM banking_customer_head_snapshot_1", &[])
|
|
728
|
+
.await
|
|
729
|
+
.unwrap();
|
|
730
|
+
assert_eq!(snapshot_rows.len(), 1, "the head-snapshot table should carry exactly the one live record");
|
|
731
|
+
let id: String = snapshot_rows[0].get(0);
|
|
732
|
+
assert_eq!(id, "CUST-0001");
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Proves `occurred_at` end to end through the real wasm module: the
|
|
736
|
+
// string this crate stamps and sends is the one that comes back out
|
|
737
|
+
// in the returned event, not just what the in-kernel unit tests prove.
|
|
738
|
+
#[tokio::test]
|
|
739
|
+
async fn occurred_at_is_stamped_onto_every_returned_event_with_this_crate_s_own_real_clock() {
|
|
740
|
+
let client = scratch_db("rust_host_dispatch_test_occurred_at").await;
|
|
741
|
+
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
742
|
+
|
|
743
|
+
let before = crate::auth::httpdate_now();
|
|
744
|
+
let outcome = handle(
|
|
745
|
+
&client,
|
|
746
|
+
&wasm_path(),
|
|
747
|
+
"Banking::Customer.Register",
|
|
748
|
+
register("CUST-OCCURRED-AT"),
|
|
749
|
+
None,
|
|
750
|
+
&test_config("Banking", 1),
|
|
751
|
+
&lambda_client::NeverInvoker,
|
|
752
|
+
)
|
|
753
|
+
.await
|
|
754
|
+
.unwrap();
|
|
755
|
+
let after = crate::auth::httpdate_now();
|
|
756
|
+
|
|
757
|
+
assert!(outcome.accepted, "{:?}", outcome.result["refusals"]);
|
|
758
|
+
let events = outcome.result["events"].as_array().unwrap();
|
|
759
|
+
assert_eq!(events.len(), 1);
|
|
760
|
+
let occurred_at = events[0]["occurred_at"].as_str().expect("occurred_at should be a real string, not null or absent");
|
|
761
|
+
// Lexical comparison is valid: this format's digit-then-letter
|
|
762
|
+
// layout sorts identically to chronological order for stamps
|
|
763
|
+
// less than 10,000 years apart.
|
|
764
|
+
assert!(occurred_at >= before.as_str() && occurred_at <= after.as_str(), "{occurred_at} should fall between {before} and {after}");
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
#[tokio::test]
|
|
768
|
+
async fn rehydrates_prior_history_before_evaluating_the_new_command() {
|
|
769
|
+
let client = scratch_db("rust_host_dispatch_test_2").await;
|
|
770
|
+
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
771
|
+
let config = test_config("Banking", 1);
|
|
772
|
+
|
|
773
|
+
let first = handle(
|
|
774
|
+
&client,
|
|
775
|
+
&wasm_path(),
|
|
776
|
+
"Banking::Customer.Register",
|
|
777
|
+
register("CUST-0001"),
|
|
778
|
+
None,
|
|
779
|
+
&config,
|
|
780
|
+
&lambda_client::NeverInvoker,
|
|
781
|
+
)
|
|
782
|
+
.await
|
|
783
|
+
.unwrap();
|
|
784
|
+
assert!(
|
|
785
|
+
first.accepted,
|
|
786
|
+
"first registration should succeed: {:?}",
|
|
787
|
+
first.result
|
|
788
|
+
);
|
|
789
|
+
|
|
790
|
+
// Proves rehydration happened, not just that two calls each
|
|
791
|
+
// succeeded independently: the second registration only refuses
|
|
792
|
+
// if the first's effect was genuinely rebuilt from Postgres.
|
|
793
|
+
let second = handle(
|
|
794
|
+
&client,
|
|
795
|
+
&wasm_path(),
|
|
796
|
+
"Banking::Customer.Register",
|
|
797
|
+
register("CUST-0001"),
|
|
798
|
+
None,
|
|
799
|
+
&config,
|
|
800
|
+
&lambda_client::NeverInvoker,
|
|
801
|
+
)
|
|
802
|
+
.await
|
|
803
|
+
.unwrap();
|
|
804
|
+
|
|
805
|
+
assert!(
|
|
806
|
+
!second.accepted,
|
|
807
|
+
"duplicate registration should be refused, proving prior state was rehydrated: {:?}",
|
|
808
|
+
second.result
|
|
809
|
+
);
|
|
810
|
+
let steps = journal::load_steps(&*client.lock().await).await.unwrap();
|
|
811
|
+
assert_eq!(
|
|
812
|
+
steps.len(),
|
|
813
|
+
1,
|
|
814
|
+
"the refused duplicate must not be persisted"
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
#[tokio::test]
|
|
819
|
+
async fn the_snapshot_stays_current_so_nothing_replays_full_history() {
|
|
820
|
+
// Direct proof (vs. the duplicate-refusal side effect above):
|
|
821
|
+
// after every accepted command, replaying past the snapshot's
|
|
822
|
+
// ordinal should find nothing left to replay.
|
|
823
|
+
let client = scratch_db("rust_host_dispatch_test_5").await;
|
|
824
|
+
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
825
|
+
let config = test_config("Banking", 1);
|
|
826
|
+
|
|
827
|
+
for reference in ["CUST-0004", "CUST-0005", "CUST-0006"] {
|
|
828
|
+
let outcome = handle(&client, &wasm_path(), "Banking::Customer.Register", register(reference), None, &config, &lambda_client::NeverInvoker)
|
|
829
|
+
.await
|
|
830
|
+
.unwrap();
|
|
831
|
+
assert!(outcome.accepted, "registering {reference} should succeed: {:?}", outcome.result);
|
|
832
|
+
|
|
833
|
+
let guard = client.lock().await;
|
|
834
|
+
let snapshot = journal::load_snapshot(&*guard)
|
|
835
|
+
.await
|
|
836
|
+
.unwrap()
|
|
837
|
+
.expect("a snapshot should exist after the first accepted command");
|
|
838
|
+
let tail = journal::load_steps_after(&*guard, snapshot.ordinal).await.unwrap();
|
|
839
|
+
assert!(tail.is_empty(), "the snapshot should already reflect every accepted command, leaving nothing to replay");
|
|
840
|
+
|
|
841
|
+
// And the seed is right, not just non-empty: it should carry
|
|
842
|
+
// every customer registered so far, not just this one.
|
|
843
|
+
let seeded = snapshot.seed.as_object().unwrap();
|
|
844
|
+
assert!(
|
|
845
|
+
seeded.keys().any(|k| k.contains(reference)),
|
|
846
|
+
"the snapshot should carry the record just dispatched: {seeded:?}"
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
let final_snapshot = journal::load_snapshot(&*client.lock().await).await.unwrap().unwrap();
|
|
851
|
+
let final_seed = final_snapshot.seed.as_object().unwrap();
|
|
852
|
+
assert_eq!(final_seed.len(), 3, "the snapshot should accumulate all three registrations, not just the latest: {final_seed:?}");
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
#[test]
|
|
856
|
+
fn a_top_level_error_is_a_kernel_failure_and_nothing_else_is() {
|
|
857
|
+
let failed = serde_json::json!({"error": "invalid seed: Registration.status: missing from JSON args"});
|
|
858
|
+
assert_eq!(classify(&failed), KernelAnswer::Failed("invalid seed: Registration.status: missing from JSON args".to_string()));
|
|
859
|
+
assert_eq!(classify(&serde_json::json!({"error": {"code": 7}})), KernelAnswer::Failed("{\"code\":7}".to_string()), "a non-text error is still a failure");
|
|
860
|
+
|
|
861
|
+
let accepted = serde_json::json!({"instances": {}, "events": [], "refusals": []});
|
|
862
|
+
assert_eq!(classify(&accepted), KernelAnswer::Accepted);
|
|
863
|
+
|
|
864
|
+
// A refusal carries its own nested `error`; that is the module deciding,
|
|
865
|
+
// not the kernel failing.
|
|
866
|
+
let refused = serde_json::json!({
|
|
867
|
+
"instances": {}, "events": [],
|
|
868
|
+
"refusals": [{"verb": "Banking::Customer.Register", "error": "already exists", "kind": "AlreadyExists"}]
|
|
869
|
+
});
|
|
870
|
+
assert_eq!(classify(&refused), KernelAnswer::Refused);
|
|
871
|
+
|
|
872
|
+
// A run whose query step failed reports it inside `queries`, still a finished run.
|
|
873
|
+
let query_error = serde_json::json!({"instances": {}, "refusals": [], "queries": [{"query": "X", "rows": null, "error": "no such query"}]});
|
|
874
|
+
assert_eq!(classify(&query_error), KernelAnswer::Accepted);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
#[test]
|
|
878
|
+
fn parse_kernel_output_turns_a_failure_into_an_error_and_keeps_refusals_as_results() {
|
|
879
|
+
let error = parse_kernel_output(r#"{"error":"invalid seed: Customer.status: missing from JSON args"}"#).unwrap_err();
|
|
880
|
+
assert!(format!("{error:#}").contains("invalid seed: Customer.status: missing from JSON args"), "{error:#}");
|
|
881
|
+
let refused = parse_kernel_output(r#"{"instances":{},"refusals":[{"verb":"v","error":"no","kind":"K"}]}"#).unwrap();
|
|
882
|
+
assert_eq!(classify(&refused), KernelAnswer::Refused);
|
|
883
|
+
assert!(parse_kernel_output("not json").is_err());
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
#[tokio::test]
|
|
887
|
+
async fn a_kernel_failure_is_an_error_and_writes_nothing() {
|
|
888
|
+
// Simulates a snapshot missing a field its shape now requires.
|
|
889
|
+
// The kernel refuses to load it; this must fail the call and
|
|
890
|
+
// leave the journal and snapshot untouched.
|
|
891
|
+
let client = scratch_db("rust_host_dispatch_kernel_failure").await;
|
|
892
|
+
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
893
|
+
let config = test_config("Banking", 1);
|
|
894
|
+
let noinvoke = lambda_client::NeverInvoker;
|
|
895
|
+
|
|
896
|
+
let first = handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0001"), None, &config, &noinvoke).await.unwrap();
|
|
897
|
+
assert!(first.accepted, "{:?}", first.result);
|
|
898
|
+
|
|
899
|
+
let whole_world = journal::load_snapshot(&*client.lock().await).await.unwrap().unwrap().seed;
|
|
900
|
+
let mut damaged = whole_world.clone();
|
|
901
|
+
let customer = damaged.as_object_mut().unwrap().values_mut().next().unwrap().as_object_mut().unwrap();
|
|
902
|
+
assert!(customer.remove("status").is_some(), "the fixture Customer carries a status");
|
|
903
|
+
client.lock().await.execute("UPDATE hecks_lambda_snapshot SET seed = $1", &[&damaged]).await.unwrap();
|
|
904
|
+
|
|
905
|
+
let before = snapshot_and_journal(&client).await;
|
|
906
|
+
let failure = handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0002"), None, &config, &noinvoke).await;
|
|
907
|
+
let message = format!("{:#}", failure.err().expect("a kernel failure must be an error, not an accepted outcome"));
|
|
908
|
+
assert!(message.contains("invalid seed") && message.contains("status"), "{message}");
|
|
909
|
+
|
|
910
|
+
assert_eq!(snapshot_and_journal(&client).await, before, "the failed command left the journal and the snapshot byte-identical");
|
|
911
|
+
assert_eq!(before.2, 1, "and only the first command was ever journaled");
|
|
912
|
+
let lineage_rows: i64 = client.lock().await.query_one("SELECT count(*) FROM hecks_journal_banking", &[]).await.unwrap().get(0);
|
|
913
|
+
assert_eq!(lineage_rows, 1, "nothing was mirrored into the era journal either");
|
|
914
|
+
|
|
915
|
+
// A read that must run the kernel over the damaged snapshot fails
|
|
916
|
+
// the same way, instead of answering an empty world.
|
|
917
|
+
client
|
|
918
|
+
.lock()
|
|
919
|
+
.await
|
|
920
|
+
.execute("INSERT INTO hecks_lambda_journal (verb, args) VALUES ($1, $2)", &[&"Banking::Customer.Register", ®ister("CUST-0009")])
|
|
921
|
+
.await
|
|
922
|
+
.unwrap();
|
|
923
|
+
let read_failure = read(&client, &wasm_path()).await.err().expect("a kernel failure is not an empty read");
|
|
924
|
+
assert!(format!("{read_failure:#}").contains("invalid seed"), "{read_failure:#}");
|
|
925
|
+
let query_failure = query(&client, &wasm_path(), "Banking::Customer.Suspended", serde_json::json!({})).await.err();
|
|
926
|
+
assert!(query_failure.is_some_and(|e| format!("{e:#}").contains("invalid seed")));
|
|
927
|
+
client.lock().await.execute("DELETE FROM hecks_lambda_journal WHERE verb = 'Banking::Customer.Register' AND ordinal > 1", &[]).await.unwrap();
|
|
928
|
+
|
|
929
|
+
// Once the snapshot is whole again the next command works, and the
|
|
930
|
+
// snapshot afterwards holds the whole world, not just its own instance.
|
|
931
|
+
client.lock().await.execute("UPDATE hecks_lambda_snapshot SET seed = $1", &[&whole_world]).await.unwrap();
|
|
932
|
+
let second = handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0002"), None, &config, &noinvoke).await.unwrap();
|
|
933
|
+
assert!(second.accepted, "{:?}", second.result);
|
|
934
|
+
let after = journal::load_snapshot(&*client.lock().await).await.unwrap().unwrap().seed;
|
|
935
|
+
let keys: Vec<&String> = after.as_object().unwrap().keys().collect();
|
|
936
|
+
assert_eq!(keys.len(), 2, "the snapshot holds both customers: {keys:?}");
|
|
937
|
+
assert!(keys.iter().any(|k| k.contains("CUST-0001")) && keys.iter().any(|k| k.contains("CUST-0002")), "{keys:?}");
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// The snapshot's ordinal and exact seed text, and how many commands are journaled.
|
|
941
|
+
async fn snapshot_and_journal(client: &Mutex<Client>) -> (i64, String, i64) {
|
|
942
|
+
let guard = client.lock().await;
|
|
943
|
+
let snapshot = guard.query_one("SELECT ordinal, seed::text FROM hecks_lambda_snapshot", &[]).await.unwrap();
|
|
944
|
+
let journaled: i64 = guard.query_one("SELECT count(*) FROM hecks_lambda_journal", &[]).await.unwrap().get(0);
|
|
945
|
+
(snapshot.get(0), snapshot.get(1), journaled)
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
#[tokio::test]
|
|
949
|
+
async fn serializes_concurrent_invocations_against_the_same_journal() {
|
|
950
|
+
// **The race the advisory lock closes**: without it, two concurrent
|
|
951
|
+
// registrations of the same reference could both rehydrate
|
|
952
|
+
// against zero prior steps, both see no conflict, and both get
|
|
953
|
+
// appended — silently violating the uniqueness the domain
|
|
954
|
+
// itself enforces. With the lock serializing the whole
|
|
955
|
+
// read-then-append sequence, exactly one must be accepted.
|
|
956
|
+
let client = scratch_db("rust_host_dispatch_test_3").await;
|
|
957
|
+
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
958
|
+
let config = test_config("Banking", 1);
|
|
959
|
+
let wasm_path = wasm_path();
|
|
960
|
+
|
|
961
|
+
let (first, second) = tokio::join!(
|
|
962
|
+
handle(&client, &wasm_path, "Banking::Customer.Register", register("CUST-0002"), None, &config, &lambda_client::NeverInvoker),
|
|
963
|
+
handle(&client, &wasm_path, "Banking::Customer.Register", register("CUST-0002"), None, &config, &lambda_client::NeverInvoker),
|
|
964
|
+
);
|
|
965
|
+
let (first, second) = (first.unwrap(), second.unwrap());
|
|
966
|
+
|
|
967
|
+
assert_ne!(
|
|
968
|
+
first.accepted, second.accepted,
|
|
969
|
+
"exactly one of two concurrent duplicate registrations should be accepted: {:?} / {:?}",
|
|
970
|
+
first.result, second.result
|
|
971
|
+
);
|
|
972
|
+
let steps = journal::load_steps(&*client.lock().await).await.unwrap();
|
|
973
|
+
assert_eq!(steps.len(), 1, "only the accepted registration should be persisted");
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
#[tokio::test]
|
|
977
|
+
async fn read_reflects_prior_dispatches_without_persisting_anything_new() {
|
|
978
|
+
let client = scratch_db("rust_host_dispatch_test_4").await;
|
|
979
|
+
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
980
|
+
|
|
981
|
+
handle(
|
|
982
|
+
&client,
|
|
983
|
+
&wasm_path(),
|
|
984
|
+
"Banking::Customer.Register",
|
|
985
|
+
register("CUST-0003"),
|
|
986
|
+
None,
|
|
987
|
+
&test_config("Banking", 1),
|
|
988
|
+
&lambda_client::NeverInvoker,
|
|
989
|
+
)
|
|
990
|
+
.await
|
|
991
|
+
.unwrap()
|
|
992
|
+
.accepted
|
|
993
|
+
.then_some(())
|
|
994
|
+
.expect("registration should succeed");
|
|
995
|
+
|
|
996
|
+
let result = read(&client, &wasm_path()).await.unwrap();
|
|
997
|
+
|
|
998
|
+
assert!(
|
|
999
|
+
result["refusals"].as_array().unwrap().is_empty(),
|
|
1000
|
+
"a read replaying only already-accepted history should never refuse: {result:?}"
|
|
1001
|
+
);
|
|
1002
|
+
let instances = result["instances"].as_object().unwrap();
|
|
1003
|
+
assert!(
|
|
1004
|
+
instances.keys().any(|k| k.contains("CUST-0003")),
|
|
1005
|
+
"read should reflect the prior dispatch: {instances:?}"
|
|
1006
|
+
);
|
|
1007
|
+
|
|
1008
|
+
// The sharp proof a read never appends: the journal is exactly
|
|
1009
|
+
// as long after the read as it was before it.
|
|
1010
|
+
let steps = journal::load_steps(&*client.lock().await).await.unwrap();
|
|
1011
|
+
assert_eq!(steps.len(), 1, "a read must never persist a journal row");
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Records every call and answers with a canned "nothing refused"
|
|
1015
|
+
// body, proving a cross-domain policy reaches `lambda_client::deliver`
|
|
1016
|
+
// with the right name/payload end to end, short of a real AWS Lambda.
|
|
1017
|
+
struct RecordingInvoker {
|
|
1018
|
+
calls: std::sync::Mutex<Vec<(String, String)>>,
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
impl RecordingInvoker {
|
|
1022
|
+
fn new() -> Self {
|
|
1023
|
+
Self { calls: std::sync::Mutex::new(Vec::new()) }
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
#[async_trait::async_trait]
|
|
1028
|
+
impl LambdaInvoker for RecordingInvoker {
|
|
1029
|
+
async fn invoke(&self, function_name: &str, payload: &str) -> anyhow::Result<lambda_client::InvokeOutcome> {
|
|
1030
|
+
self.calls.lock().unwrap().push((function_name.to_string(), payload.to_string()));
|
|
1031
|
+
Ok(lambda_client::InvokeOutcome { body: serde_json::json!({ "refusals": [] }), function_error: false })
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
#[tokio::test]
|
|
1036
|
+
async fn a_cross_domain_policy_delivers_through_the_real_rehydrate_replay_path() {
|
|
1037
|
+
let client = scratch_db("rust_host_dispatch_test_6").await;
|
|
1038
|
+
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer", "Account"]).await;
|
|
1039
|
+
let config = test_config("Banking", 1);
|
|
1040
|
+
let invoker = RecordingInvoker::new();
|
|
1041
|
+
|
|
1042
|
+
handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0007"), None, &config, &invoker)
|
|
1043
|
+
.await
|
|
1044
|
+
.unwrap()
|
|
1045
|
+
.accepted
|
|
1046
|
+
.then_some(())
|
|
1047
|
+
.expect("registration should succeed");
|
|
1048
|
+
|
|
1049
|
+
let open_args = serde_json::json!({
|
|
1050
|
+
"number": { "value": "acct-freeze-me" },
|
|
1051
|
+
"kind": { "name": "current" },
|
|
1052
|
+
"daily_limit": { "cents": 50000 },
|
|
1053
|
+
"customer": "CUST-0007",
|
|
1054
|
+
});
|
|
1055
|
+
handle(&client, &wasm_path(), "Banking::Account.Open", open_args, None, &config, &invoker)
|
|
1056
|
+
.await
|
|
1057
|
+
.unwrap()
|
|
1058
|
+
.accepted
|
|
1059
|
+
.then_some(())
|
|
1060
|
+
.expect("account open should succeed");
|
|
1061
|
+
|
|
1062
|
+
// `FreezeAccount` announces `AccountFrozen`, matched by
|
|
1063
|
+
// `ReviewOnFreeze` (`across "Compliance"`) — the real trigger
|
|
1064
|
+
// this feature exists for.
|
|
1065
|
+
let freeze_args = serde_json::json!({ "number": { "value": "acct-freeze-me" } });
|
|
1066
|
+
let outcome = handle(&client, &wasm_path(), "Banking::Account.FreezeAccount", freeze_args, None, &config, &invoker)
|
|
1067
|
+
.await
|
|
1068
|
+
.unwrap();
|
|
1069
|
+
assert!(outcome.accepted, "freeze should succeed: {:?}", outcome.result);
|
|
1070
|
+
|
|
1071
|
+
let deliveries = outcome.result["cross_domain_deliveries"].as_array().unwrap();
|
|
1072
|
+
assert_eq!(deliveries.len(), 1, "exactly one cross-domain reaction should have fired: {deliveries:?}");
|
|
1073
|
+
assert_eq!(deliveries[0]["policy"], "ReviewOnFreeze");
|
|
1074
|
+
assert_eq!(deliveries[0]["target_domain"], "Compliance");
|
|
1075
|
+
assert_eq!(deliveries[0]["delivered"], true);
|
|
1076
|
+
|
|
1077
|
+
// The same delivery also merges into "reactions"
|
|
1078
|
+
// ({policy, on, trigger, delivered, reason}), alongside the
|
|
1079
|
+
// differently-shaped "cross_domain_deliveries" entry above.
|
|
1080
|
+
let reactions = outcome.result["reactions"].as_array().unwrap();
|
|
1081
|
+
let merged = reactions.iter().find(|r| r["policy"] == "ReviewOnFreeze").expect("ReviewOnFreeze should appear in \"reactions\" too");
|
|
1082
|
+
assert_eq!(merged["on"], "AccountFrozen");
|
|
1083
|
+
assert_eq!(merged["trigger"], "Compliance::AccountFreezeReview.Open");
|
|
1084
|
+
assert_eq!(merged["delivered"], true);
|
|
1085
|
+
|
|
1086
|
+
{
|
|
1087
|
+
let calls = invoker.calls.lock().unwrap();
|
|
1088
|
+
assert_eq!(calls.len(), 1);
|
|
1089
|
+
let (function_name, payload) = &calls[0];
|
|
1090
|
+
assert_eq!(function_name, "hecks-compliance");
|
|
1091
|
+
let sent: serde_json::Value = serde_json::from_str(payload).unwrap();
|
|
1092
|
+
// Fully qualified, not the bare "Compliance.OpenReview":
|
|
1093
|
+
// generated match arms are always "Domain::Aggregate.Command",
|
|
1094
|
+
// and Compliance's own aggregate is named `AccountFreezeReview`.
|
|
1095
|
+
assert_eq!(sent["verb"], "Compliance::AccountFreezeReview.Open");
|
|
1096
|
+
assert_eq!(sent["args"]["number"]["value"], "acct-freeze-me");
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Never re-delivered on a later replay: `pending_cross_domain`
|
|
1100
|
+
// only ever reads the last step's own reactions, so rehydrating
|
|
1101
|
+
// history here doesn't re-invoke the sibling Lambda.
|
|
1102
|
+
handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0008"), None, &config, &invoker)
|
|
1103
|
+
.await
|
|
1104
|
+
.unwrap();
|
|
1105
|
+
assert_eq!(invoker.calls.lock().unwrap().len(), 1, "replaying prior history must not re-deliver its cross-domain reaction");
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Pins the fix: this is the first test that reaches `check_role`
|
|
1109
|
+
// with a caller role actually bound and checked, not the unchecked
|
|
1110
|
+
// "no role at all" path every other test in this file uses.
|
|
1111
|
+
// `Register` requires `Some("Branch clerk")` in the generated
|
|
1112
|
+
// registry — real, corpus-declared.
|
|
1113
|
+
#[tokio::test]
|
|
1114
|
+
async fn a_role_gated_command_is_actually_checked_against_the_caller_role() {
|
|
1115
|
+
let client = scratch_db("rust_host_dispatch_test_7").await;
|
|
1116
|
+
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
1117
|
+
let config = test_config("Banking", 1);
|
|
1118
|
+
|
|
1119
|
+
// The correct role succeeds, same as every other registration,
|
|
1120
|
+
// just now with a caller actually bound and matching.
|
|
1121
|
+
let matching = handle(
|
|
1122
|
+
&client,
|
|
1123
|
+
&wasm_path(),
|
|
1124
|
+
"Banking::Customer.Register",
|
|
1125
|
+
register("CUST-0009"),
|
|
1126
|
+
Some("Branch clerk"),
|
|
1127
|
+
&config,
|
|
1128
|
+
&lambda_client::NeverInvoker,
|
|
1129
|
+
)
|
|
1130
|
+
.await
|
|
1131
|
+
.unwrap();
|
|
1132
|
+
assert!(
|
|
1133
|
+
matching.accepted,
|
|
1134
|
+
"the declared role should be admitted: {:?}",
|
|
1135
|
+
matching.result
|
|
1136
|
+
);
|
|
1137
|
+
|
|
1138
|
+
// The actual proof: with role genuinely threaded through, a
|
|
1139
|
+
// caller stating the wrong role must be refused, not silently
|
|
1140
|
+
// admitted via the unchecked no-role path.
|
|
1141
|
+
let mismatched = handle(
|
|
1142
|
+
&client,
|
|
1143
|
+
&wasm_path(),
|
|
1144
|
+
"Banking::Customer.Register",
|
|
1145
|
+
register("CUST-0010"),
|
|
1146
|
+
Some("Teller"),
|
|
1147
|
+
&config,
|
|
1148
|
+
&lambda_client::NeverInvoker,
|
|
1149
|
+
)
|
|
1150
|
+
.await
|
|
1151
|
+
.unwrap();
|
|
1152
|
+
assert!(
|
|
1153
|
+
!mismatched.accepted,
|
|
1154
|
+
"a caller stating the wrong role must be refused, not silently admitted: {:?}",
|
|
1155
|
+
mismatched.result
|
|
1156
|
+
);
|
|
1157
|
+
let refusals = mismatched.result["refusals"].as_array().unwrap();
|
|
1158
|
+
assert_eq!(refusals.len(), 1);
|
|
1159
|
+
let error = refusals[0]["error"].as_str().unwrap();
|
|
1160
|
+
assert!(
|
|
1161
|
+
error.contains("Branch clerk") && error.contains("Teller"),
|
|
1162
|
+
"the refusal should name both the declared role and the caller's mismatched one \
|
|
1163
|
+
(`check_role`'s own message shape): {refusals:?}"
|
|
1164
|
+
);
|
|
1165
|
+
|
|
1166
|
+
// And the refused command was never persisted — same discipline
|
|
1167
|
+
// every other refusal in this file is held to.
|
|
1168
|
+
let steps = journal::load_steps(&*client.lock().await).await.unwrap();
|
|
1169
|
+
assert_eq!(steps.len(), 1, "only the correctly-authorized registration should be persisted");
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
#[tokio::test]
|
|
1173
|
+
async fn a_mid_transaction_failure_rolls_back_any_saga_state_already_written() {
|
|
1174
|
+
// Deliberately doesn't provision "Transfer": its mutation fails
|
|
1175
|
+
// after `begin_saga` already wrote into `hecks_lambda_sagas`,
|
|
1176
|
+
// proving that write rolls back with the rest of the transaction.
|
|
1177
|
+
let client = scratch_db("rust_host_dispatch_test_9").await;
|
|
1178
|
+
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer", "Account"]).await;
|
|
1179
|
+
let config = test_config("Banking", 1);
|
|
1180
|
+
|
|
1181
|
+
handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0012"), None, &config, &lambda_client::NeverInvoker)
|
|
1182
|
+
.await.unwrap().accepted.then_some(()).expect("registration should succeed");
|
|
1183
|
+
|
|
1184
|
+
let open_src = serde_json::json!({
|
|
1185
|
+
"number": { "value": "src-rollback" }, "kind": { "name": "current" },
|
|
1186
|
+
"daily_limit": { "cents": 50000 }, "customer": "CUST-0012",
|
|
1187
|
+
});
|
|
1188
|
+
handle(&client, &wasm_path(), "Banking::Account.Open", open_src, None, &config, &lambda_client::NeverInvoker)
|
|
1189
|
+
.await.unwrap().accepted.then_some(()).expect("source account open should succeed");
|
|
1190
|
+
let open_dst = serde_json::json!({
|
|
1191
|
+
"number": { "value": "dst-rollback" }, "kind": { "name": "current" },
|
|
1192
|
+
"daily_limit": { "cents": 50000 }, "customer": "CUST-0012",
|
|
1193
|
+
});
|
|
1194
|
+
handle(&client, &wasm_path(), "Banking::Account.Open", open_dst, None, &config, &lambda_client::NeverInvoker)
|
|
1195
|
+
.await.unwrap().accepted.then_some(()).expect("destination account open should succeed");
|
|
1196
|
+
handle(
|
|
1197
|
+
&client, &wasm_path(), "Banking::Account.Credit",
|
|
1198
|
+
serde_json::json!({ "number": "src-rollback", "amount": { "cents": 1000 }, "narrative": { "text": "opening balance" } }),
|
|
1199
|
+
None, &config, &lambda_client::NeverInvoker,
|
|
1200
|
+
)
|
|
1201
|
+
.await.unwrap().accepted.then_some(()).expect("credit should succeed");
|
|
1202
|
+
|
|
1203
|
+
// `Transfer.Request`'s own mutation is the first one this step
|
|
1204
|
+
// produces, and "Transfer" was never provisioned, so it fails
|
|
1205
|
+
// before the saga cascade runs further.
|
|
1206
|
+
let transfer_args = serde_json::json!({
|
|
1207
|
+
"reference": { "value": "tr-rollback" }, "amount": { "cents": 200 },
|
|
1208
|
+
"narrative": { "text": "rollback test" }, "source": "src-rollback", "destination": "dst-rollback",
|
|
1209
|
+
});
|
|
1210
|
+
let outcome = handle(&client, &wasm_path(), "Banking::Transfer.Request", transfer_args, None, &config, &lambda_client::NeverInvoker).await;
|
|
1211
|
+
assert!(outcome.is_err(), "the unprovisioned Transfer lineage table should make this whole invocation fail");
|
|
1212
|
+
|
|
1213
|
+
let guard = client.lock().await;
|
|
1214
|
+
let saga_rows = guard.query("SELECT process_manager, correlation FROM hecks_lambda_sagas", &[]).await.unwrap();
|
|
1215
|
+
assert_eq!(
|
|
1216
|
+
saga_rows.len(), 0,
|
|
1217
|
+
"a saga write inside a transaction that ultimately fails must roll back with it, not commit independently: {saga_rows:?}"
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
#[tokio::test]
|
|
1222
|
+
async fn a_pre_existing_snapshot_without_the_sagas_backfilled_latch_forces_one_full_replay() {
|
|
1223
|
+
let client = scratch_db("rust_host_dispatch_test_10").await;
|
|
1224
|
+
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer", "Account", "Transfer"]).await;
|
|
1225
|
+
let config = test_config("Banking", 1);
|
|
1226
|
+
// `ReviewOnFreeze` fires on every `FreezeAccount` in this domain
|
|
1227
|
+
// (proven above), so `NeverInvoker` would panic here.
|
|
1228
|
+
let invoker = RecordingInvoker::new();
|
|
1229
|
+
|
|
1230
|
+
handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0013"), None, &config, &invoker)
|
|
1231
|
+
.await.unwrap().accepted.then_some(()).expect("registration should succeed");
|
|
1232
|
+
|
|
1233
|
+
let open_src = serde_json::json!({
|
|
1234
|
+
"number": { "value": "src-backfill" }, "kind": { "name": "current" },
|
|
1235
|
+
"daily_limit": { "cents": 50000 }, "customer": "CUST-0013",
|
|
1236
|
+
});
|
|
1237
|
+
handle(&client, &wasm_path(), "Banking::Account.Open", open_src, None, &config, &invoker)
|
|
1238
|
+
.await.unwrap().accepted.then_some(()).expect("source account open should succeed");
|
|
1239
|
+
let open_dst = serde_json::json!({
|
|
1240
|
+
"number": { "value": "dst-backfill" }, "kind": { "name": "current" },
|
|
1241
|
+
"daily_limit": { "cents": 50000 }, "customer": "CUST-0013",
|
|
1242
|
+
});
|
|
1243
|
+
handle(&client, &wasm_path(), "Banking::Account.Open", open_dst, None, &config, &invoker)
|
|
1244
|
+
.await.unwrap().accepted.then_some(()).expect("destination account open should succeed");
|
|
1245
|
+
handle(
|
|
1246
|
+
&client, &wasm_path(), "Banking::Account.Credit",
|
|
1247
|
+
serde_json::json!({ "number": "src-backfill", "amount": { "cents": 1000 }, "narrative": { "text": "opening balance" } }),
|
|
1248
|
+
None, &config, &invoker,
|
|
1249
|
+
)
|
|
1250
|
+
.await.unwrap().accepted.then_some(()).expect("credit should succeed");
|
|
1251
|
+
|
|
1252
|
+
// Freezing the destination first means the credit leg refuses
|
|
1253
|
+
// and Settlement compensates, leaving the saga stuck "reversed"
|
|
1254
|
+
// — exactly the mid-flight shape this test needs to recover.
|
|
1255
|
+
handle(&client, &wasm_path(), "Banking::Account.FreezeAccount", serde_json::json!({ "number": { "value": "dst-backfill" } }), None, &config, &invoker)
|
|
1256
|
+
.await.unwrap().accepted.then_some(()).expect("freeze should succeed");
|
|
1257
|
+
let transfer_args = serde_json::json!({
|
|
1258
|
+
"reference": { "value": "tr-backfill" }, "amount": { "cents": 200 },
|
|
1259
|
+
"narrative": { "text": "backfill test" }, "source": "src-backfill", "destination": "dst-backfill",
|
|
1260
|
+
});
|
|
1261
|
+
handle(&client, &wasm_path(), "Banking::Transfer.Request", transfer_args, None, &config, &invoker)
|
|
1262
|
+
.await.unwrap().accepted.then_some(()).expect("the Transfer.Request COMMAND succeeds; the saga's own credit leg is what refuses");
|
|
1263
|
+
|
|
1264
|
+
{
|
|
1265
|
+
let guard = client.lock().await;
|
|
1266
|
+
let before = guard.query("SELECT process_manager, correlation, state FROM hecks_lambda_sagas", &[]).await.unwrap();
|
|
1267
|
+
assert_eq!(before.len(), 1, "the reversed Settlement instance should already be tracked before the simulated reset: {before:?}");
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Simulates a pre-saga-durability snapshot: the latch false and
|
|
1271
|
+
// `hecks_lambda_sagas` empty, even though a saga is genuinely
|
|
1272
|
+
// mid-flight.
|
|
1273
|
+
{
|
|
1274
|
+
let guard = client.lock().await;
|
|
1275
|
+
guard.execute("UPDATE hecks_lambda_snapshot SET sagas_backfilled = false", &[]).await.unwrap();
|
|
1276
|
+
guard.execute("DELETE FROM hecks_lambda_sagas", &[]).await.unwrap();
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// One more command should trigger a full replay, whose saga
|
|
1280
|
+
// cascade re-running as a side effect repopulates
|
|
1281
|
+
// `hecks_lambda_sagas`.
|
|
1282
|
+
handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0014"), None, &config, &invoker)
|
|
1283
|
+
.await.unwrap().accepted.then_some(()).expect("second registration should succeed");
|
|
1284
|
+
|
|
1285
|
+
let guard = client.lock().await;
|
|
1286
|
+
let saga_rows = guard.query("SELECT process_manager, correlation, state FROM hecks_lambda_sagas", &[]).await.unwrap();
|
|
1287
|
+
assert_eq!(saga_rows.len(), 1, "the backfill replay should have recovered the in-flight Settlement instance: {saga_rows:?}");
|
|
1288
|
+
let process_manager: String = saga_rows[0].get(0);
|
|
1289
|
+
let correlation: String = saga_rows[0].get(1);
|
|
1290
|
+
let state: String = saga_rows[0].get(2);
|
|
1291
|
+
assert_eq!(process_manager, "Settlement");
|
|
1292
|
+
assert_eq!(correlation, "tr-backfill");
|
|
1293
|
+
assert_eq!(state, "reversed");
|
|
1294
|
+
|
|
1295
|
+
let latched: bool = guard.query_one("SELECT sagas_backfilled FROM hecks_lambda_snapshot", &[]).await.unwrap().get(0);
|
|
1296
|
+
assert!(latched, "after the backfill replay, the latch should be set so this doesn't repeat every invocation");
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// Always fails with a hard invoke fault, the shape an unreachable
|
|
1300
|
+
// Lambda has — proves the exhausted-retry/dead-letter path.
|
|
1301
|
+
struct AlwaysFailingInvoker {
|
|
1302
|
+
calls: std::sync::Mutex<u32>,
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
impl AlwaysFailingInvoker {
|
|
1306
|
+
fn new() -> Self {
|
|
1307
|
+
Self { calls: std::sync::Mutex::new(0) }
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
#[async_trait::async_trait]
|
|
1312
|
+
impl LambdaInvoker for AlwaysFailingInvoker {
|
|
1313
|
+
async fn invoke(&self, _function_name: &str, _payload: &str) -> anyhow::Result<lambda_client::InvokeOutcome> {
|
|
1314
|
+
*self.calls.lock().unwrap() += 1;
|
|
1315
|
+
anyhow::bail!("ResourceNotFoundException: function not found")
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
#[tokio::test]
|
|
1320
|
+
async fn a_cross_domain_delivery_that_exhausts_every_retry_dead_letters_and_still_fails_the_invocation() {
|
|
1321
|
+
let client = scratch_db("rust_host_dispatch_test_8").await;
|
|
1322
|
+
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer", "Account"]).await;
|
|
1323
|
+
let config = test_config("Banking", 1);
|
|
1324
|
+
let invoker = AlwaysFailingInvoker::new();
|
|
1325
|
+
|
|
1326
|
+
handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0011"), None, &config, &invoker)
|
|
1327
|
+
.await
|
|
1328
|
+
.unwrap()
|
|
1329
|
+
.accepted
|
|
1330
|
+
.then_some(())
|
|
1331
|
+
.expect("registration should succeed");
|
|
1332
|
+
|
|
1333
|
+
let open_args = serde_json::json!({
|
|
1334
|
+
"number": { "value": "acct-freeze-dead-letter" },
|
|
1335
|
+
"kind": { "name": "current" },
|
|
1336
|
+
"daily_limit": { "cents": 50000 },
|
|
1337
|
+
"customer": "CUST-0011",
|
|
1338
|
+
});
|
|
1339
|
+
handle(&client, &wasm_path(), "Banking::Account.Open", open_args, None, &config, &invoker)
|
|
1340
|
+
.await
|
|
1341
|
+
.unwrap()
|
|
1342
|
+
.accepted
|
|
1343
|
+
.then_some(())
|
|
1344
|
+
.expect("account open should succeed");
|
|
1345
|
+
|
|
1346
|
+
// `ReviewOnFreeze` fires here — every attempt against
|
|
1347
|
+
// `AlwaysFailingInvoker` fails, so `deliver_with_retry` exhausts
|
|
1348
|
+
// `MAX_DELIVERY_ATTEMPTS` before `handle` ever sees a result.
|
|
1349
|
+
let freeze_args = serde_json::json!({ "number": { "value": "acct-freeze-dead-letter" } });
|
|
1350
|
+
let outcome = handle(&client, &wasm_path(), "Banking::Account.FreezeAccount", freeze_args, None, &config, &invoker).await;
|
|
1351
|
+
|
|
1352
|
+
assert!(
|
|
1353
|
+
outcome.is_err(),
|
|
1354
|
+
"a cross-domain delivery that exhausts every retry should still fail THIS invocation \
|
|
1355
|
+
visibly — the local Freeze already committed regardless (checked below), this is only \
|
|
1356
|
+
about what the CALLER sees"
|
|
1357
|
+
);
|
|
1358
|
+
assert_eq!(
|
|
1359
|
+
*invoker.calls.lock().unwrap(),
|
|
1360
|
+
lambda_client::MAX_DELIVERY_ATTEMPTS,
|
|
1361
|
+
"should have retried exactly MAX_DELIVERY_ATTEMPTS times before giving up"
|
|
1362
|
+
);
|
|
1363
|
+
|
|
1364
|
+
// The local command still committed: cross-domain delivery runs
|
|
1365
|
+
// strictly after commit, so a delivery failure never unwinds it.
|
|
1366
|
+
// The dead letter existing at all proves the match already ran.
|
|
1367
|
+
let guard = client.lock().await;
|
|
1368
|
+
let dead_letters = guard
|
|
1369
|
+
.query("SELECT policy, target_domain, target_verb, attempts FROM hecks_cross_domain_dead_letters", &[])
|
|
1370
|
+
.await
|
|
1371
|
+
.unwrap();
|
|
1372
|
+
assert_eq!(dead_letters.len(), 1, "exactly one exhausted delivery should have been dead-lettered: {dead_letters:?}");
|
|
1373
|
+
let policy: String = dead_letters[0].get(0);
|
|
1374
|
+
let target_domain: String = dead_letters[0].get(1);
|
|
1375
|
+
let target_verb: String = dead_letters[0].get(2);
|
|
1376
|
+
let attempts: i32 = dead_letters[0].get(3);
|
|
1377
|
+
assert_eq!(policy, "ReviewOnFreeze");
|
|
1378
|
+
assert_eq!(target_domain, "Compliance");
|
|
1379
|
+
assert_eq!(target_verb, "Compliance::AccountFreezeReview.Open");
|
|
1380
|
+
assert_eq!(attempts, lambda_client::MAX_DELIVERY_ATTEMPTS as i32);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Pins a real drop: a second cross-domain reaction from the same
|
|
1384
|
+
// step must not go dark just because the first one's delivery
|
|
1385
|
+
// exhausted its retries. `SafeDepositBox.Surrender` emits both
|
|
1386
|
+
// `BoxSurrendered` (-> Compliance) and `KeyReturnDue` (-> Notifications)
|
|
1387
|
+
// from one command.
|
|
1388
|
+
#[tokio::test]
|
|
1389
|
+
async fn a_failed_cross_domain_delivery_does_not_drop_a_sibling_reaction_from_the_same_step() {
|
|
1390
|
+
let client = scratch_db("rust_host_dispatch_test_11").await;
|
|
1391
|
+
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer", "SafeDepositBox"]).await;
|
|
1392
|
+
let config = test_config("Banking", 1);
|
|
1393
|
+
let invoker = AlwaysFailingInvoker::new();
|
|
1394
|
+
|
|
1395
|
+
handle(&client, &wasm_path(), "Banking::Customer.Register", register("CUST-0022"), None, &config, &invoker)
|
|
1396
|
+
.await
|
|
1397
|
+
.unwrap()
|
|
1398
|
+
.accepted
|
|
1399
|
+
.then_some(())
|
|
1400
|
+
.expect("registration should succeed");
|
|
1401
|
+
|
|
1402
|
+
let rent_args = serde_json::json!({
|
|
1403
|
+
"branch_code": { "value": "downtown" },
|
|
1404
|
+
"box_number": { "value": 12 },
|
|
1405
|
+
"size": { "value": "small" },
|
|
1406
|
+
"customer": "CUST-0022",
|
|
1407
|
+
});
|
|
1408
|
+
handle(&client, &wasm_path(), "Banking::SafeDepositBox.Rent", rent_args, None, &config, &invoker)
|
|
1409
|
+
.await
|
|
1410
|
+
.unwrap()
|
|
1411
|
+
.accepted
|
|
1412
|
+
.then_some(())
|
|
1413
|
+
.expect("rent should succeed");
|
|
1414
|
+
|
|
1415
|
+
// Both ReviewOnBoxSurrender (-> Compliance) and FlagKeyReturn
|
|
1416
|
+
// (-> Notifications) fire here; both deliveries exhaust every
|
|
1417
|
+
// retry against AlwaysFailingInvoker.
|
|
1418
|
+
let surrender_args = serde_json::json!({ "branch_code": { "value": "downtown" }, "box_number": { "value": 12 } });
|
|
1419
|
+
let outcome =
|
|
1420
|
+
handle(&client, &wasm_path(), "Banking::SafeDepositBox.Surrender", surrender_args, None, &config, &invoker)
|
|
1421
|
+
.await;
|
|
1422
|
+
|
|
1423
|
+
assert!(outcome.is_err(), "still fails the invocation visibly, same contract as a single failed delivery");
|
|
1424
|
+
assert_eq!(
|
|
1425
|
+
*invoker.calls.lock().unwrap(),
|
|
1426
|
+
lambda_client::MAX_DELIVERY_ATTEMPTS * 2,
|
|
1427
|
+
"BOTH reactions should have been attempted MAX_DELIVERY_ATTEMPTS times each — \
|
|
1428
|
+
the fix under test is exactly this: a fresh Vec would only ever show one policy's worth"
|
|
1429
|
+
);
|
|
1430
|
+
|
|
1431
|
+
let guard = client.lock().await;
|
|
1432
|
+
let dead_letters = guard
|
|
1433
|
+
.query("SELECT policy FROM hecks_cross_domain_dead_letters ORDER BY policy", &[])
|
|
1434
|
+
.await
|
|
1435
|
+
.unwrap();
|
|
1436
|
+
let policies: Vec<String> = dead_letters.iter().map(|row| row.get(0)).collect();
|
|
1437
|
+
assert_eq!(
|
|
1438
|
+
policies,
|
|
1439
|
+
vec!["FlagKeyReturn".to_string(), "ReviewOnBoxSurrender".to_string()],
|
|
1440
|
+
"BOTH sibling reactions must be dead-lettered, not just whichever ran first: {policies:?}"
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
}
|