hecks 2.9.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/exe/hecks +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 +4 -2
- 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 +7 -4
- 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 +6 -3
- 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,1596 @@
|
|
|
1
|
+
//! The console's `/api/*` surface: answers `/api/me`, `/api/presentation`,
|
|
2
|
+
//! `/api/ui-schema`, `/api/schema` and `/api/:coll` in the Ruby engine's own shapes.
|
|
3
|
+
|
|
4
|
+
use crate::auth::Session;
|
|
5
|
+
use crate::dispatch;
|
|
6
|
+
use crate::journal::LineageConfig;
|
|
7
|
+
use crate::lambda_client::LambdaInvoker;
|
|
8
|
+
use crate::presentation;
|
|
9
|
+
use crate::presentation_write;
|
|
10
|
+
use crate::ui_schema;
|
|
11
|
+
use crate::web::{percent_decode, respond};
|
|
12
|
+
use serde_json::{json, Map, Value};
|
|
13
|
+
use std::cmp::Ordering;
|
|
14
|
+
use std::collections::HashMap;
|
|
15
|
+
use std::path::Path;
|
|
16
|
+
use tokio::sync::Mutex;
|
|
17
|
+
use tokio_postgres::Client;
|
|
18
|
+
|
|
19
|
+
/// Every `/api/...` request, answered. `session` is `None` only in tests —
|
|
20
|
+
/// `web::route` runs `auth_gate` first in production — but it stays an
|
|
21
|
+
/// `Option` because `/api/me` itself has a real empty-session answer.
|
|
22
|
+
#[allow(clippy::too_many_arguments)]
|
|
23
|
+
pub async fn route(
|
|
24
|
+
domain_ir: &Value,
|
|
25
|
+
method: &str,
|
|
26
|
+
path: &str,
|
|
27
|
+
query: &HashMap<String, String>,
|
|
28
|
+
raw_body: &str,
|
|
29
|
+
session: Option<&Session>,
|
|
30
|
+
client: &Mutex<Client>,
|
|
31
|
+
wasm_path: &Path,
|
|
32
|
+
config: &LineageConfig,
|
|
33
|
+
invoker: &dyn LambdaInvoker,
|
|
34
|
+
) -> Value {
|
|
35
|
+
match (method, path) {
|
|
36
|
+
("GET", "/api/me") => ok(&me(session)),
|
|
37
|
+
|
|
38
|
+
// The whole UI, derived: nav, columns, field shapes, transitions,
|
|
39
|
+
// create forms. Read fresh every call so a Settings-screen save is
|
|
40
|
+
// visible on the app's very next request.
|
|
41
|
+
("GET", "/api/ui-schema") => match presentation::load(client, wasm_path, config).await {
|
|
42
|
+
Ok(presentation) => ok(&ui_schema::build(domain_ir, &presentation)),
|
|
43
|
+
Err(e) => internal_error(&e.to_string()),
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// Structural facts only, no presentation opinion: aggregates,
|
|
47
|
+
// lifecycle states, queries.
|
|
48
|
+
("GET", "/api/schema") => match presentation::load(client, wasm_path, config).await {
|
|
49
|
+
Ok(presentation) => ok(&ui_schema::schema(domain_ir, &presentation)),
|
|
50
|
+
Err(e) => internal_error(&e.to_string()),
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
// Read fresh every call, never memoized, for the same reason.
|
|
54
|
+
("GET", "/api/presentation") => match presentation::load(client, wasm_path, config).await {
|
|
55
|
+
Ok(presentation) => ok(&presentation),
|
|
56
|
+
Err(e) => internal_error(&e.to_string()),
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
("PUT", "/api/presentation") => {
|
|
60
|
+
presentation_save(domain_ir, raw_body, client, wasm_path, config, invoker).await
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Collection routes (`/api/:coll[/:id]`), matched last so `:coll`
|
|
64
|
+
// doesn't swallow `me`/`schema`. Segments are percent-decoded: a
|
|
65
|
+
// record id is often an email or a slug from `encodeURIComponent`.
|
|
66
|
+
_ => {
|
|
67
|
+
let segments: Vec<String> = path
|
|
68
|
+
.strip_prefix("/api/")
|
|
69
|
+
.unwrap_or_default()
|
|
70
|
+
.split('/')
|
|
71
|
+
.filter(|s| !s.is_empty())
|
|
72
|
+
.map(percent_decode)
|
|
73
|
+
.collect();
|
|
74
|
+
|
|
75
|
+
match (method, segments.as_slice()) {
|
|
76
|
+
("GET", [collection]) => {
|
|
77
|
+
collection_index(domain_ir, collection, query, client, wasm_path, config).await
|
|
78
|
+
}
|
|
79
|
+
("GET", [collection, id]) => record_show(domain_ir, collection, id, client, wasm_path, config).await,
|
|
80
|
+
("POST", [collection]) => {
|
|
81
|
+
collection_create(domain_ir, collection, raw_body, client, wasm_path, config, invoker).await
|
|
82
|
+
}
|
|
83
|
+
("POST", [collection, id, command]) => {
|
|
84
|
+
command_route(domain_ir, collection, id, command, raw_body, client, wasm_path, config, invoker).await
|
|
85
|
+
}
|
|
86
|
+
_ => not_found(&format!("no API route for {method} {path}")),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// `GET /api/:coll` — every record (`.all()`), or, with `?query=<name>`,
|
|
93
|
+
/// one of the aggregate's declared queries; falls back to `.all()` on an
|
|
94
|
+
/// unknown name or a missing required argument.
|
|
95
|
+
async fn collection_index(
|
|
96
|
+
domain_ir: &Value,
|
|
97
|
+
collection: &str,
|
|
98
|
+
params: &HashMap<String, String>,
|
|
99
|
+
client: &Mutex<Client>,
|
|
100
|
+
wasm_path: &Path,
|
|
101
|
+
lineage: &LineageConfig,
|
|
102
|
+
) -> Value {
|
|
103
|
+
let config = match presentation::load(client, wasm_path, lineage).await {
|
|
104
|
+
Ok(config) => config,
|
|
105
|
+
Err(e) => return internal_error(&e.to_string()),
|
|
106
|
+
};
|
|
107
|
+
let aggregate = match resolve_collection(domain_ir, &config, collection) {
|
|
108
|
+
Ok(aggregate) => aggregate,
|
|
109
|
+
Err(refusal) => return refusal,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
let wanted = params.get("query").map(String::as_str).filter(|name| !name.is_empty());
|
|
113
|
+
let named = wanted.and_then(|name| find_query(aggregate, name));
|
|
114
|
+
if let Some(named) = named {
|
|
115
|
+
if let Some(args) = query_args_from_params(aggregate, named, params) {
|
|
116
|
+
return named_query_rows(domain_ir, aggregate, named, args, client, wasm_path).await;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let mut records = match read_records(domain_ir, aggregate, client, wasm_path).await {
|
|
121
|
+
Ok(records) => records,
|
|
122
|
+
Err(refusal) => return refusal,
|
|
123
|
+
};
|
|
124
|
+
sort_records(&mut records, &resolve_sort(domain_ir, &config, collection, aggregate, params));
|
|
125
|
+
ok(&Value::Array(records.into_iter().map(|(id, state)| with_id(&id, &state)).collect()))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// `GET /api/:coll/:id` — one record's full state, or the Ruby
|
|
129
|
+
/// engine's own `Runtime::NotFound` message, verbatim.
|
|
130
|
+
async fn record_show(
|
|
131
|
+
domain_ir: &Value,
|
|
132
|
+
collection: &str,
|
|
133
|
+
id: &str,
|
|
134
|
+
client: &Mutex<Client>,
|
|
135
|
+
wasm_path: &Path,
|
|
136
|
+
lineage: &LineageConfig,
|
|
137
|
+
) -> Value {
|
|
138
|
+
let config = match presentation::load(client, wasm_path, lineage).await {
|
|
139
|
+
Ok(config) => config,
|
|
140
|
+
Err(e) => return internal_error(&e.to_string()),
|
|
141
|
+
};
|
|
142
|
+
let aggregate = match resolve_collection(domain_ir, &config, collection) {
|
|
143
|
+
Ok(aggregate) => aggregate,
|
|
144
|
+
Err(refusal) => return refusal,
|
|
145
|
+
};
|
|
146
|
+
let records = match read_records(domain_ir, aggregate, client, wasm_path).await {
|
|
147
|
+
Ok(records) => records,
|
|
148
|
+
Err(refusal) => return refusal,
|
|
149
|
+
};
|
|
150
|
+
match records.into_iter().find(|(record_id, _)| record_id == id) {
|
|
151
|
+
Some((record_id, state)) => ok(&with_id(&record_id, &state)),
|
|
152
|
+
None => not_found(&format!("no {} found for id {:?}", ui_schema::agg_name(aggregate), id)),
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/// A collection key is presentation, not domain (`collections.<Name>.key`
|
|
157
|
+
/// can rename it), so this resolves through the same `collection_key`
|
|
158
|
+
/// `/api/ui-schema` hands the client, regardless of lifecycle.
|
|
159
|
+
fn resolve_collection<'a>(domain_ir: &'a Value, config: &Value, collection: &str) -> Result<&'a Value, Value> {
|
|
160
|
+
ui_schema::aggregates(domain_ir)
|
|
161
|
+
.into_iter()
|
|
162
|
+
.find(|aggregate| ui_schema::collection_key(aggregate, config) == collection)
|
|
163
|
+
.ok_or_else(|| not_found(&format!("no such collection: {collection}")))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
fn find_query<'a>(aggregate: &'a Value, name: &str) -> Option<&'a Value> {
|
|
167
|
+
aggregate
|
|
168
|
+
.get("queries")
|
|
169
|
+
.and_then(|v| v.as_array())?
|
|
170
|
+
.iter()
|
|
171
|
+
.find(|query| query.get("name").and_then(|v| v.as_str()) == Some(name))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// Every live record of one aggregate, keyed by id — this host's own
|
|
175
|
+
/// `instances` read, filtered to one `"Domain::Aggregate#"` prefix.
|
|
176
|
+
async fn read_records(
|
|
177
|
+
domain_ir: &Value,
|
|
178
|
+
aggregate: &Value,
|
|
179
|
+
client: &Mutex<Client>,
|
|
180
|
+
wasm_path: &Path,
|
|
181
|
+
) -> Result<Vec<(String, Value)>, Value> {
|
|
182
|
+
let result = dispatch::read(client, wasm_path).await.map_err(|e| internal_error(&format!("{e:#}")))?;
|
|
183
|
+
let prefix = format!(
|
|
184
|
+
"{}::{}#",
|
|
185
|
+
domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or(""),
|
|
186
|
+
ui_schema::agg_name(aggregate)
|
|
187
|
+
);
|
|
188
|
+
Ok(result
|
|
189
|
+
.get("instances")
|
|
190
|
+
.and_then(|v| v.as_object())
|
|
191
|
+
.map(|instances| {
|
|
192
|
+
instances
|
|
193
|
+
.iter()
|
|
194
|
+
.filter_map(|(key, state)| key.strip_prefix(&prefix).map(|id| (id.to_string(), state.clone())))
|
|
195
|
+
.collect()
|
|
196
|
+
})
|
|
197
|
+
.unwrap_or_default())
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/// `Handle#to_h` ends `state.merge(id: @id)` — the record's own
|
|
201
|
+
/// already-reduced scalar identity, added beside its real fields.
|
|
202
|
+
fn with_id(id: &str, state: &Value) -> Value {
|
|
203
|
+
let mut record = state.clone();
|
|
204
|
+
if let Some(object) = record.as_object_mut() {
|
|
205
|
+
object.insert("id".to_string(), json!(id));
|
|
206
|
+
}
|
|
207
|
+
record
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async fn named_query_rows(
|
|
211
|
+
domain_ir: &Value,
|
|
212
|
+
aggregate: &Value,
|
|
213
|
+
named: &Value,
|
|
214
|
+
args: Value,
|
|
215
|
+
client: &Mutex<Client>,
|
|
216
|
+
wasm_path: &Path,
|
|
217
|
+
) -> Value {
|
|
218
|
+
let question = format!(
|
|
219
|
+
"{}::{}.{}",
|
|
220
|
+
domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or(""),
|
|
221
|
+
ui_schema::agg_name(aggregate),
|
|
222
|
+
named.get("name").and_then(|v| v.as_str()).unwrap_or("")
|
|
223
|
+
);
|
|
224
|
+
let result = match dispatch::query(client, wasm_path, &question, args).await {
|
|
225
|
+
Ok(result) => result,
|
|
226
|
+
Err(e) => return internal_error(&format!("{e:#}")),
|
|
227
|
+
};
|
|
228
|
+
let answered = result.get("queries").and_then(|v| v.as_array()).and_then(|queries| queries.first()).cloned();
|
|
229
|
+
let Some(answered) = answered else {
|
|
230
|
+
return internal_error(&format!("the kernel answered no query step for {question}"));
|
|
231
|
+
};
|
|
232
|
+
match answered.get("rows").filter(|rows| !rows.is_null()) {
|
|
233
|
+
Some(rows) => ok(rows),
|
|
234
|
+
// A refused query, not a missing one — the refusal lands in the
|
|
235
|
+
// kernel's own top-level `refusals` array, read the same way a
|
|
236
|
+
// refused command is.
|
|
237
|
+
None => domain_refusal(&result),
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/// Same-named params, shaped per argument (bare for a reference/primitive,
|
|
242
|
+
/// wrapped for a value object). `None` the moment a required one is missing.
|
|
243
|
+
fn query_args_from_params(aggregate: &Value, named: &Value, params: &HashMap<String, String>) -> Option<Value> {
|
|
244
|
+
let mut args = Map::new();
|
|
245
|
+
for attribute in named.get("attributes").and_then(|v| v.as_array())? {
|
|
246
|
+
let name = attribute.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
247
|
+
let raw = params.get(name).filter(|value| !value.is_empty());
|
|
248
|
+
let Some(raw) = raw else {
|
|
249
|
+
if attribute.get("optional").and_then(|v| v.as_bool()).unwrap_or(false) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
return None;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
let ty = attribute.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
|
256
|
+
let bare = ui_schema::reference_target(ty).is_some() || PRIMITIVES.contains(&ty);
|
|
257
|
+
if bare {
|
|
258
|
+
args.insert(name.to_string(), json!(raw));
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
let wire_key = value_object_field(aggregate, ty)?;
|
|
262
|
+
args.insert(name.to_string(), json!({ wire_key: raw }));
|
|
263
|
+
}
|
|
264
|
+
Some(Value::Object(args))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const PRIMITIVES: &[&str] = &["String", "Integer", "Float", "TrueClass", "FalseClass"];
|
|
268
|
+
|
|
269
|
+
fn value_object_field(aggregate: &Value, type_name: &str) -> Option<String> {
|
|
270
|
+
let value_object = aggregate
|
|
271
|
+
.get("value_objects")
|
|
272
|
+
.and_then(|v| v.as_array())?
|
|
273
|
+
.iter()
|
|
274
|
+
.find(|v| v.get("name").and_then(|n| n.as_str()) == Some(type_name))?;
|
|
275
|
+
let first = value_object.get("attributes").and_then(|v| v.as_array())?.first()?;
|
|
276
|
+
first.get("name").and_then(|v| v.as_str()).map(String::from)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---- POST /api/:coll, POST /api/:coll/:id/:command -------------------
|
|
280
|
+
|
|
281
|
+
/// `POST /api/:coll` — the aggregate's creating command, plus what the
|
|
282
|
+
/// console does around it: minting an identity (`apply_identity`) and
|
|
283
|
+
/// checking a cross-aggregate precondition a `given` can't express
|
|
284
|
+
/// (`check_preconditions`), both driven by `collections.<Name>` config.
|
|
285
|
+
#[allow(clippy::too_many_arguments)]
|
|
286
|
+
async fn collection_create(
|
|
287
|
+
domain_ir: &Value,
|
|
288
|
+
collection: &str,
|
|
289
|
+
raw_body: &str,
|
|
290
|
+
client: &Mutex<Client>,
|
|
291
|
+
wasm_path: &Path,
|
|
292
|
+
config: &LineageConfig,
|
|
293
|
+
invoker: &dyn LambdaInvoker,
|
|
294
|
+
) -> Value {
|
|
295
|
+
let presentation = match presentation::load(client, wasm_path, config).await {
|
|
296
|
+
Ok(presentation) => presentation,
|
|
297
|
+
Err(e) => return internal_error(&e.to_string()),
|
|
298
|
+
};
|
|
299
|
+
let aggregate = match resolve_collection(domain_ir, &presentation, collection) {
|
|
300
|
+
Ok(aggregate) => aggregate,
|
|
301
|
+
Err(refusal) => return refusal,
|
|
302
|
+
};
|
|
303
|
+
let mut args = match parsed_body(raw_body) {
|
|
304
|
+
Ok(args) => args,
|
|
305
|
+
Err(refusal) => return refusal,
|
|
306
|
+
};
|
|
307
|
+
let name = ui_schema::agg_name(aggregate);
|
|
308
|
+
let Some(creating) = ui_schema::commands(aggregate).into_iter().find(|c| ui_schema::creates(c)) else {
|
|
309
|
+
return not_found(&format!("{name} declares no creating command"));
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
let records = match read_records(domain_ir, aggregate, client, wasm_path).await {
|
|
313
|
+
Ok(records) => records,
|
|
314
|
+
Err(refusal) => return refusal,
|
|
315
|
+
};
|
|
316
|
+
if let Err(refusal) = apply_identity(&presentation, aggregate, &mut args, records.len()) {
|
|
317
|
+
return refusal;
|
|
318
|
+
}
|
|
319
|
+
if let Err(refusal) = check_preconditions(domain_ir, &presentation, aggregate, &args, client, wasm_path).await {
|
|
320
|
+
return refusal;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let verb = format!("{}::{name}.{}", domain_name(domain_ir), command_name(creating));
|
|
324
|
+
let outcome = match dispatch::handle_facts(client, wasm_path, &verb, args, None, config, invoker).await {
|
|
325
|
+
Ok(outcome) => outcome,
|
|
326
|
+
Err(e) => return internal_error(&format!("{e:#}")),
|
|
327
|
+
};
|
|
328
|
+
if !outcome.accepted {
|
|
329
|
+
return domain_refusal(&outcome.result);
|
|
330
|
+
}
|
|
331
|
+
// The id this call's own command targeted: the first mutation of the
|
|
332
|
+
// last step, not whichever mutation a side-effect policy pushed on
|
|
333
|
+
// after it.
|
|
334
|
+
let id = created_id(&outcome.result).unwrap_or_default();
|
|
335
|
+
created_record(domain_ir, aggregate, &outcome.result, &id)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/// `POST /api/:coll/:id/:command` — a command against an existing record.
|
|
339
|
+
/// Order matters: find the record first (404), then the command (404),
|
|
340
|
+
/// then dispatch — the Ruby engine's own order.
|
|
341
|
+
#[allow(clippy::too_many_arguments)]
|
|
342
|
+
async fn command_route(
|
|
343
|
+
domain_ir: &Value,
|
|
344
|
+
collection: &str,
|
|
345
|
+
id: &str,
|
|
346
|
+
command: &str,
|
|
347
|
+
raw_body: &str,
|
|
348
|
+
client: &Mutex<Client>,
|
|
349
|
+
wasm_path: &Path,
|
|
350
|
+
config: &LineageConfig,
|
|
351
|
+
invoker: &dyn LambdaInvoker,
|
|
352
|
+
) -> Value {
|
|
353
|
+
let presentation = match presentation::load(client, wasm_path, config).await {
|
|
354
|
+
Ok(presentation) => presentation,
|
|
355
|
+
Err(e) => return internal_error(&e.to_string()),
|
|
356
|
+
};
|
|
357
|
+
let aggregate = match resolve_collection(domain_ir, &presentation, collection) {
|
|
358
|
+
Ok(aggregate) => aggregate,
|
|
359
|
+
Err(refusal) => return refusal,
|
|
360
|
+
};
|
|
361
|
+
let name = ui_schema::agg_name(aggregate);
|
|
362
|
+
let records = match read_records(domain_ir, aggregate, client, wasm_path).await {
|
|
363
|
+
Ok(records) => records,
|
|
364
|
+
Err(refusal) => return refusal,
|
|
365
|
+
};
|
|
366
|
+
if !records.iter().any(|(record_id, _)| record_id == id) {
|
|
367
|
+
return not_found(&format!("no {name} found for id {id:?}"));
|
|
368
|
+
}
|
|
369
|
+
let Some(declared) = dispatchable_command(aggregate, command) else {
|
|
370
|
+
return not_found(&format!("{name} declares no command named {command:?}"));
|
|
371
|
+
};
|
|
372
|
+
let args = match parsed_body(raw_body) {
|
|
373
|
+
Ok(args) => args,
|
|
374
|
+
Err(refusal) => return refusal,
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
let verb = format!("{}::{name}.{}", domain_name(domain_ir), command_name(declared));
|
|
378
|
+
let outcome = match dispatch::handle_routed(client, wasm_path, &verb, json!(id), args, None, config, invoker).await {
|
|
379
|
+
Ok(outcome) => outcome,
|
|
380
|
+
Err(e) => return internal_error(&format!("{e:#}")),
|
|
381
|
+
};
|
|
382
|
+
if !outcome.accepted {
|
|
383
|
+
return domain_refusal(&outcome.result);
|
|
384
|
+
}
|
|
385
|
+
created_record(domain_ir, aggregate, &outcome.result, id)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/// Every command except the creating one, which is reached only through
|
|
389
|
+
/// `POST /api/:coll`. Matched against the snake_cased name
|
|
390
|
+
/// `/api/ui-schema` hands the client, not the declared name itself.
|
|
391
|
+
fn dispatchable_command<'a>(aggregate: &'a Value, wanted: &str) -> Option<&'a Value> {
|
|
392
|
+
ui_schema::commands(aggregate)
|
|
393
|
+
.into_iter()
|
|
394
|
+
.filter(|command| !ui_schema::creates(command))
|
|
395
|
+
.find(|command| ui_schema::snake(command_name(command)) == wanted)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
fn command_name(command: &Value) -> &str {
|
|
399
|
+
command.get("name").and_then(|v| v.as_str()).unwrap_or("")
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
fn domain_name(domain_ir: &Value) -> &str {
|
|
403
|
+
domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or("")
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/// An empty body is an empty argument hash; anything that isn't a JSON
|
|
407
|
+
/// object refuses (400) rather than reaching the kernel with the wrong
|
|
408
|
+
/// shape.
|
|
409
|
+
fn parsed_body(raw: &str) -> Result<Value, Value> {
|
|
410
|
+
if raw.trim().is_empty() {
|
|
411
|
+
return Ok(json!({}));
|
|
412
|
+
}
|
|
413
|
+
match serde_json::from_str::<Value>(raw) {
|
|
414
|
+
Ok(Value::Object(body)) => Ok(Value::Object(body)),
|
|
415
|
+
_ => Err(refusal(400, "MalformedBody", "request body is not valid JSON")),
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/// The command's own target id: the first mutation of the last step.
|
|
420
|
+
fn created_id(result: &Value) -> Option<String> {
|
|
421
|
+
result
|
|
422
|
+
.get("mutations")?
|
|
423
|
+
.as_array()?
|
|
424
|
+
.last()?
|
|
425
|
+
.as_array()?
|
|
426
|
+
.first()?
|
|
427
|
+
.get("id")?
|
|
428
|
+
.as_str()
|
|
429
|
+
.map(String::from)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/// The record as it stands after the command, read straight out of the
|
|
433
|
+
/// kernel's post-dispatch `instances` rather than re-reading the journal.
|
|
434
|
+
fn created_record(domain_ir: &Value, aggregate: &Value, result: &Value, id: &str) -> Value {
|
|
435
|
+
let key = format!("{}::{}#{id}", domain_name(domain_ir), ui_schema::agg_name(aggregate));
|
|
436
|
+
match result.get("instances").and_then(|instances| instances.get(&key)) {
|
|
437
|
+
Some(state) => ok(&with_id(id, state)),
|
|
438
|
+
// Accepted, but this host cannot see the record it just wrote
|
|
439
|
+
// — a real inconsistency, never a 200 with an empty body.
|
|
440
|
+
None => internal_error(&format!("{key} was accepted but is not in the resulting state")),
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/// A refused command, in the Ruby engine's own envelope (422, refusal
|
|
445
|
+
/// class as `error`). The last refusal, not the first: `dispatch::handle`
|
|
446
|
+
/// replays the whole rehydrated history, and every earlier step already
|
|
447
|
+
/// succeeded once.
|
|
448
|
+
fn domain_refusal(result: &Value) -> Value {
|
|
449
|
+
let last = result.get("refusals").and_then(|r| r.as_array()).and_then(|refusals| refusals.last());
|
|
450
|
+
let Some(last) = last else { return refusal(422, "Refused", "the command was refused") };
|
|
451
|
+
refusal(
|
|
452
|
+
422,
|
|
453
|
+
last.get("kind").and_then(|v| v.as_str()).unwrap_or("Refused"),
|
|
454
|
+
last.get("error").and_then(|v| v.as_str()).unwrap_or("the command was refused"),
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ---- identity minting ------------------------------------------------
|
|
459
|
+
|
|
460
|
+
/// Mints an identity field per `collections.<Name>.identity`: `slug`
|
|
461
|
+
/// derives it from another submitted field, `sequence` pads the next
|
|
462
|
+
/// count. Only fires when the field is genuinely missing; a caller that
|
|
463
|
+
/// supplies one is left alone.
|
|
464
|
+
fn apply_identity(presentation: &Value, aggregate: &Value, args: &mut Value, existing: usize) -> Result<(), Value> {
|
|
465
|
+
let name = ui_schema::agg_name(aggregate);
|
|
466
|
+
let Some(rule) = presentation.get("collections").and_then(|c| c.get(name)).and_then(|c| c.get("identity")) else {
|
|
467
|
+
return Ok(());
|
|
468
|
+
};
|
|
469
|
+
let Some(field) = rule.get("field").and_then(|v| v.as_str()) else { return Ok(()) };
|
|
470
|
+
if args.get(field).is_some() {
|
|
471
|
+
return Ok(());
|
|
472
|
+
}
|
|
473
|
+
let Some(wire_key) = identity_wire_key(aggregate, field) else { return Ok(()) };
|
|
474
|
+
|
|
475
|
+
let minted = match rule.get("strategy").and_then(|v| v.as_str()) {
|
|
476
|
+
Some("slug") => {
|
|
477
|
+
let source = rule.get("source").and_then(|v| v.as_str()).unwrap_or("");
|
|
478
|
+
Some(slugify(&dig_source(args, source)))
|
|
479
|
+
}
|
|
480
|
+
Some("sequence") => {
|
|
481
|
+
let prefix = rule.get("prefix").and_then(|v| v.as_str()).unwrap_or("");
|
|
482
|
+
let pad = rule.get("pad").and_then(|v| v.as_u64()).unwrap_or(3) as usize;
|
|
483
|
+
Some(format!("{prefix}{:0>pad$}", existing + 1))
|
|
484
|
+
}
|
|
485
|
+
// `port` delegates to a Ruby adapter this host has no runtime for
|
|
486
|
+
// (ADR 0007); refusing names the real reason rather than
|
|
487
|
+
// dispatching without the identity field.
|
|
488
|
+
Some("port") => {
|
|
489
|
+
return Err(not_implemented(
|
|
490
|
+
"this collection mints its identity through the domain's own identity_assignment port, which this host has no adapter runtime to call — create through the Ruby console engine, or configure a slug or sequence strategy instead",
|
|
491
|
+
))
|
|
492
|
+
}
|
|
493
|
+
_ => None,
|
|
494
|
+
};
|
|
495
|
+
let Some(minted) = minted.filter(|value| !value.is_empty()) else { return Ok(()) };
|
|
496
|
+
if let Some(object) = args.as_object_mut() {
|
|
497
|
+
object.insert(field.to_string(), json!({ wire_key: minted }));
|
|
498
|
+
}
|
|
499
|
+
Ok(())
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/// The wire key an identity value has to be wrapped in — whatever the
|
|
503
|
+
/// target value object's own single attribute is actually called. This
|
|
504
|
+
/// domain always spells it "value"; nothing here assumes that.
|
|
505
|
+
fn identity_wire_key(aggregate: &Value, field: &str) -> Option<String> {
|
|
506
|
+
let attribute = ui_schema::find_attribute(aggregate, field)?;
|
|
507
|
+
let ty = attribute.get("type").and_then(|v| v.as_str())?;
|
|
508
|
+
if ui_schema::reference_target(ty).is_some() {
|
|
509
|
+
return None;
|
|
510
|
+
}
|
|
511
|
+
value_object_field(aggregate, ty)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
fn dig_source(args: &Value, source: &str) -> String {
|
|
515
|
+
match args.get(source) {
|
|
516
|
+
Some(Value::Object(fields)) => fields.values().next().map(scalar_to_string).unwrap_or_default(),
|
|
517
|
+
Some(value) => scalar_to_string(value),
|
|
518
|
+
None => String::new(),
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
fn scalar_to_string(value: &Value) -> String {
|
|
523
|
+
match value {
|
|
524
|
+
Value::String(text) => text.clone(),
|
|
525
|
+
Value::Null => String::new(),
|
|
526
|
+
other => other.to_string(),
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/// `slugify` — lowercase, every run of non-alphanumerics to a single
|
|
531
|
+
/// hyphen, trimmed, and never empty (an unsluggable source becomes
|
|
532
|
+
/// "record" rather than an identity of no characters at all).
|
|
533
|
+
fn slugify(text: &str) -> String {
|
|
534
|
+
let mut slug = String::new();
|
|
535
|
+
for character in text.trim().to_lowercase().chars() {
|
|
536
|
+
if character.is_ascii_alphanumeric() {
|
|
537
|
+
slug.push(character);
|
|
538
|
+
} else if !slug.ends_with('-') {
|
|
539
|
+
slug.push('-');
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
let slug = slug.trim_matches('-').to_string();
|
|
543
|
+
if slug.is_empty() {
|
|
544
|
+
"record".to_string()
|
|
545
|
+
} else {
|
|
546
|
+
slug
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ---- preconditions ---------------------------------------------------
|
|
551
|
+
|
|
552
|
+
/// Cross-aggregate preconditions from `collections.<Name>.preconditions`
|
|
553
|
+
/// config, which a creating command's own `given` can't express because
|
|
554
|
+
/// it can only read its own aggregate.
|
|
555
|
+
async fn check_preconditions(
|
|
556
|
+
domain_ir: &Value,
|
|
557
|
+
presentation: &Value,
|
|
558
|
+
aggregate: &Value,
|
|
559
|
+
args: &Value,
|
|
560
|
+
client: &Mutex<Client>,
|
|
561
|
+
wasm_path: &Path,
|
|
562
|
+
) -> Result<(), Value> {
|
|
563
|
+
let name = ui_schema::agg_name(aggregate);
|
|
564
|
+
let rules = presentation
|
|
565
|
+
.get("collections")
|
|
566
|
+
.and_then(|c| c.get(name))
|
|
567
|
+
.and_then(|c| c.get("preconditions"))
|
|
568
|
+
.and_then(|v| v.as_array())
|
|
569
|
+
.cloned()
|
|
570
|
+
.unwrap_or_default();
|
|
571
|
+
if rules.is_empty() {
|
|
572
|
+
return Ok(());
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
let instances = dispatch::read(client, wasm_path).await.map_err(|e| internal_error(&format!("{e:#}")))?;
|
|
576
|
+
let instances = instances.get("instances").cloned().unwrap_or_else(|| json!({}));
|
|
577
|
+
|
|
578
|
+
for rule in &rules {
|
|
579
|
+
check_precondition(domain_ir, aggregate, rule, args, &instances)?;
|
|
580
|
+
}
|
|
581
|
+
Ok(())
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
fn check_precondition(domain_ir: &Value, aggregate: &Value, rule: &Value, args: &Value, instances: &Value) -> Result<(), Value> {
|
|
585
|
+
let Some(field) = rule.get("field").and_then(|v| v.as_str()) else { return Ok(()) };
|
|
586
|
+
// A missing required reference is the command's own validation to
|
|
587
|
+
// refuse, not this one's.
|
|
588
|
+
let Some(target_id) = args.get(field).and_then(|v| v.as_str()) else { return Ok(()) };
|
|
589
|
+
// Config named a field that isn't a reference at all — caught at
|
|
590
|
+
// save time by the console's own validation, not at dispatch time.
|
|
591
|
+
let Some(target_name) = ui_schema::find_attribute(aggregate, field)
|
|
592
|
+
.and_then(|attribute| attribute.get("type"))
|
|
593
|
+
.and_then(|v| v.as_str())
|
|
594
|
+
.and_then(ui_schema::reference_target)
|
|
595
|
+
else {
|
|
596
|
+
return Ok(());
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
let key = format!("{}::{target_name}#{target_id}", domain_name(domain_ir));
|
|
600
|
+
let Some(target) = instances.get(&key) else {
|
|
601
|
+
return Err(precondition_failed(
|
|
602
|
+
rule,
|
|
603
|
+
&format!("no {} with that id", target_name.to_lowercase()),
|
|
604
|
+
));
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
let Some(expected) = rule.get("state").and_then(|v| v.as_str()) else { return Ok(()) };
|
|
608
|
+
let target_aggregate = ui_schema::aggregates(domain_ir).into_iter().find(|a| ui_schema::agg_name(a) == target_name);
|
|
609
|
+
// Config named a state precondition against an aggregate with no
|
|
610
|
+
// lifecycle — again a save-time concern, not a dispatch-time one.
|
|
611
|
+
let Some(state_field) = target_aggregate
|
|
612
|
+
.and_then(|a| a.get("lifecycle").cloned())
|
|
613
|
+
.filter(|l| !l.is_null())
|
|
614
|
+
.and_then(|l| l.get("field").and_then(|v| v.as_str()).map(String::from))
|
|
615
|
+
else {
|
|
616
|
+
return Ok(());
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
let actual = target.get(&state_field).and_then(|v| v.as_str()).unwrap_or("");
|
|
620
|
+
if actual == expected {
|
|
621
|
+
return Ok(());
|
|
622
|
+
}
|
|
623
|
+
Err(precondition_failed(rule, &format!("{target_name} is at {actual}, not {expected}")))
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
fn precondition_failed(rule: &Value, derived: &str) -> Value {
|
|
627
|
+
refusal(422, "PreconditionFailed", rule.get("message").and_then(|v| v.as_str()).unwrap_or(derived))
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ---- sorting ---------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
/// Column shapes a JSONB order path resolves correctly. A fold over a
|
|
633
|
+
/// list or a cross-aggregate join is excluded and stays client-side.
|
|
634
|
+
const SQL_SORTABLE_SHAPES: &[&str] = &["text_value", "text", "address", "number", "money", "enum", "pill"];
|
|
635
|
+
|
|
636
|
+
struct SortSpec {
|
|
637
|
+
field: String,
|
|
638
|
+
descending: bool,
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/// `?sort=<column key>&direction=asc|desc`, checked against this
|
|
642
|
+
/// collection's sortable config. Falls back to default order (`None`)
|
|
643
|
+
/// when the column isn't sortable or its shape can't push to SQL.
|
|
644
|
+
fn resolve_sort(
|
|
645
|
+
domain_ir: &Value,
|
|
646
|
+
config: &Value,
|
|
647
|
+
collection: &str,
|
|
648
|
+
aggregate: &Value,
|
|
649
|
+
params: &HashMap<String, String>,
|
|
650
|
+
) -> Option<SortSpec> {
|
|
651
|
+
let key = params.get("sort").filter(|key| !key.is_empty())?;
|
|
652
|
+
let schema = ui_schema::build(domain_ir, config);
|
|
653
|
+
let collection_schema = schema.get("collections")?.get(collection)?;
|
|
654
|
+
let column = collection_schema
|
|
655
|
+
.get("columns")?
|
|
656
|
+
.as_array()?
|
|
657
|
+
.iter()
|
|
658
|
+
.find(|column| column.get("key").and_then(|v| v.as_str()) == Some(key.as_str()))?;
|
|
659
|
+
|
|
660
|
+
if column.get("sortable") == Some(&json!(false)) {
|
|
661
|
+
return None;
|
|
662
|
+
}
|
|
663
|
+
let shape = column.get("shape").and_then(|v| v.as_str()).unwrap_or("");
|
|
664
|
+
if !SQL_SORTABLE_SHAPES.contains(&shape) {
|
|
665
|
+
return None;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
let field = if key == "__state__" {
|
|
669
|
+
collection_schema.get("stateField")?.as_str()?.to_string()
|
|
670
|
+
} else {
|
|
671
|
+
key.to_string()
|
|
672
|
+
};
|
|
673
|
+
// Config could still name a field the aggregate doesn't have.
|
|
674
|
+
if ui_schema::find_attribute(aggregate, &field).is_none() && lifecycle_field(aggregate) != Some(field.as_str()) {
|
|
675
|
+
return None;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
Some(SortSpec { field, descending: params.get("direction").map(String::as_str) == Some("desc") })
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
fn lifecycle_field(aggregate: &Value) -> Option<&str> {
|
|
682
|
+
aggregate.get("lifecycle")?.get("field")?.as_str()
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/// In memory, not SQL: there's no ORDER BY to push into, so this follows
|
|
686
|
+
/// Postgres's own null ordering, with a stable id fallback for ties.
|
|
687
|
+
fn sort_records(records: &mut [(String, Value)], spec: &Option<SortSpec>) {
|
|
688
|
+
match spec {
|
|
689
|
+
None => records.sort_by(|(a, _), (b, _)| a.cmp(b)),
|
|
690
|
+
Some(spec) => records.sort_by(|(a_id, a), (b_id, b)| {
|
|
691
|
+
let ordering = compare_sort_keys(sort_key(a, &spec.field), sort_key(b, &spec.field), spec.descending);
|
|
692
|
+
ordering.then_with(|| a_id.cmp(b_id))
|
|
693
|
+
}),
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/// The scalar a JSONB order path lands on: the field itself, `cents` for
|
|
698
|
+
/// a money value object, or a single-attribute value object's member.
|
|
699
|
+
fn sort_key<'a>(state: &'a Value, field: &str) -> Option<&'a Value> {
|
|
700
|
+
let value = state.get(field).filter(|v| !v.is_null())?;
|
|
701
|
+
let Some(object) = value.as_object() else { return Some(value) };
|
|
702
|
+
if let Some(cents) = object.get("cents") {
|
|
703
|
+
return Some(cents);
|
|
704
|
+
}
|
|
705
|
+
if object.len() == 1 {
|
|
706
|
+
return object.values().next().filter(|v| !v.is_null());
|
|
707
|
+
}
|
|
708
|
+
Some(value)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
fn compare_sort_keys(left: Option<&Value>, right: Option<&Value>, descending: bool) -> Ordering {
|
|
712
|
+
let ordering = match (left, right) {
|
|
713
|
+
(None, None) => Ordering::Equal,
|
|
714
|
+
// NULLS LAST ascending; reversing below puts them first for
|
|
715
|
+
// descending, which is Postgres's own default pairing.
|
|
716
|
+
(None, Some(_)) => Ordering::Greater,
|
|
717
|
+
(Some(_), None) => Ordering::Less,
|
|
718
|
+
(Some(left), Some(right)) => compare_values(left, right),
|
|
719
|
+
};
|
|
720
|
+
// A null's placement is not reversed with the direction — Postgres
|
|
721
|
+
// moves nulls to the front for DESC by keeping the same rule and
|
|
722
|
+
// reversing everything, which is exactly what this does.
|
|
723
|
+
if descending {
|
|
724
|
+
ordering.reverse()
|
|
725
|
+
} else {
|
|
726
|
+
ordering
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
fn compare_values(left: &Value, right: &Value) -> Ordering {
|
|
731
|
+
match (left.as_f64(), right.as_f64()) {
|
|
732
|
+
(Some(left), Some(right)) => left.partial_cmp(&right).unwrap_or(Ordering::Equal),
|
|
733
|
+
_ => left.as_str().unwrap_or("").cmp(right.as_str().unwrap_or("")),
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/// `GET /api/me` — `{"email", "name", "identity_id", "role"}` from
|
|
738
|
+
/// `session`, or `{}` with no session, which index.html treats as
|
|
739
|
+
/// "don't render the signed-in banner".
|
|
740
|
+
fn me(session: Option<&Session>) -> Value {
|
|
741
|
+
let Some(session) = session else { return json!({}) };
|
|
742
|
+
json!({
|
|
743
|
+
"email": session.email,
|
|
744
|
+
"name": session.name,
|
|
745
|
+
"identity_id": session.identity_id,
|
|
746
|
+
"role": session.role,
|
|
747
|
+
})
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/// `PUT /api/presentation` — the whole config replaced at once, validated
|
|
751
|
+
/// against this domain's live shape, answered with the reloaded config.
|
|
752
|
+
/// 200 on save; 400 `MalformedBody`; 422 `Malformed` or a domain refusal;
|
|
753
|
+
/// 501 when this host's kernel carries no ConsoleSettings chapter.
|
|
754
|
+
#[allow(clippy::too_many_arguments)]
|
|
755
|
+
async fn presentation_save(
|
|
756
|
+
domain_ir: &Value,
|
|
757
|
+
raw_body: &str,
|
|
758
|
+
client: &Mutex<Client>,
|
|
759
|
+
wasm_path: &Path,
|
|
760
|
+
config: &LineageConfig,
|
|
761
|
+
invoker: &dyn LambdaInvoker,
|
|
762
|
+
) -> Value {
|
|
763
|
+
let submitted = match parsed_body(raw_body) {
|
|
764
|
+
Ok(submitted) => submitted,
|
|
765
|
+
Err(refusal) => return refusal,
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
match presentation_write::save(domain_ir, &submitted, client, wasm_path, config, invoker).await {
|
|
769
|
+
Ok(saved) => ok(&saved),
|
|
770
|
+
Err(presentation_write::SaveRefusal::NoKernel) => not_implemented(PRESENTATION_WRITE_REFUSAL),
|
|
771
|
+
Err(presentation_write::SaveRefusal::Malformed(message)) => refusal(422, "Malformed", &message),
|
|
772
|
+
Err(presentation_write::SaveRefusal::Domain(result)) => domain_refusal(&result),
|
|
773
|
+
Err(presentation_write::SaveRefusal::Internal(message)) => internal_error(&message),
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Fires only for a domain whose kernel genuinely has no ConsoleSettings
|
|
778
|
+
// chapter; names the decision outstanding rather than 404-ing as if the
|
|
779
|
+
// route never existed.
|
|
780
|
+
const PRESENTATION_WRITE_REFUSAL: &str =
|
|
781
|
+
"this host's kernel carries no ConsoleSettings chapter, so it has no StateStyle/Collection/Overview \
|
|
782
|
+
commands to dispatch, and writing the rows behind its back would skip the invariants those commands \
|
|
783
|
+
enforce. Attach it with `uses_framework \"ConsoleSettings\"` in this domain's own .hecksagon and \
|
|
784
|
+
rebuild the kernel, or save from the Ruby console engine instead.";
|
|
785
|
+
|
|
786
|
+
// ---- response envelopes ---------------------------------------------
|
|
787
|
+
|
|
788
|
+
fn ok(body: &Value) -> Value {
|
|
789
|
+
respond(200, "application/json", &body.to_string())
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
pub(crate) fn not_found(message: &str) -> Value {
|
|
793
|
+
refusal(404, "NotFound", message)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
pub(crate) fn internal_error(message: &str) -> Value {
|
|
797
|
+
refusal(500, "InternalError", message)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
fn not_implemented(message: &str) -> Value {
|
|
801
|
+
refusal(501, "NotImplemented", message)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
pub(crate) fn refusal(status: u16, error: &str, message: &str) -> Value {
|
|
805
|
+
respond(status, "application/json", &json!({"error": error, "message": message}).to_string())
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
#[cfg(test)]
|
|
809
|
+
mod tests {
|
|
810
|
+
use super::*;
|
|
811
|
+
|
|
812
|
+
fn session() -> Session {
|
|
813
|
+
Session {
|
|
814
|
+
identity_id: "11111111-2222-3333-4444-555555555555".to_string(),
|
|
815
|
+
email: "chris@example.com".to_string(),
|
|
816
|
+
name: "Chris".to_string(),
|
|
817
|
+
role: Some("Admin".to_string()),
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
fn body(response: &Value) -> Value {
|
|
822
|
+
serde_json::from_str(response["body"].as_str().expect("a JSON body")).expect("valid JSON")
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// app.rb: `get "/api/me" { json(session[:member] || {}) }`, where
|
|
826
|
+
// session[:member] is embryonaut_access_control.rb's own
|
|
827
|
+
// `{ "email" =>, "name" =>, "identity_id" =>, "role" => }`.
|
|
828
|
+
#[test]
|
|
829
|
+
fn me_is_the_ruby_engines_own_member_hash() {
|
|
830
|
+
assert_eq!(
|
|
831
|
+
me(Some(&session())),
|
|
832
|
+
json!({
|
|
833
|
+
"email": "chris@example.com",
|
|
834
|
+
"name": "Chris",
|
|
835
|
+
"identity_id": "11111111-2222-3333-4444-555555555555",
|
|
836
|
+
"role": "Admin"
|
|
837
|
+
})
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// A Member with a session but no granted role reads `role: null`,
|
|
842
|
+
// not a missing key — Ruby's own hash always carries all four keys
|
|
843
|
+
// (`role` comes back nil when Governance holds no grant and the
|
|
844
|
+
// aggregate's own field is unset).
|
|
845
|
+
#[test]
|
|
846
|
+
fn me_keeps_a_null_role_rather_than_dropping_the_key() {
|
|
847
|
+
let mut session = session();
|
|
848
|
+
session.role = None;
|
|
849
|
+
assert_eq!(me(Some(&session))["role"], Value::Null);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
#[test]
|
|
853
|
+
fn me_with_no_session_is_the_empty_object_ruby_answers() {
|
|
854
|
+
assert_eq!(me(None), json!({}));
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
#[test]
|
|
858
|
+
fn an_unknown_api_route_is_a_json_404_not_an_html_one() {
|
|
859
|
+
let response = not_found("no API route for GET /api/nope/nope/nope");
|
|
860
|
+
assert_eq!(response["statusCode"], 404);
|
|
861
|
+
assert_eq!(response["headers"]["content-type"], "application/json");
|
|
862
|
+
assert_eq!(body(&response)["error"], "NotFound");
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// The refusal envelope every non-2xx answer in this module shares,
|
|
866
|
+
// pinned against app.rb's own `json({ error:, message: })`.
|
|
867
|
+
#[test]
|
|
868
|
+
fn a_refusal_carries_exactly_error_and_message() {
|
|
869
|
+
let response = refusal(422, "PreconditionFailed", "the proposal hasn't been accepted yet");
|
|
870
|
+
assert_eq!(response["statusCode"], 422);
|
|
871
|
+
assert_eq!(
|
|
872
|
+
response["body"],
|
|
873
|
+
r#"{"error":"PreconditionFailed","message":"the proposal hasn't been accepted yet"}"#
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// ---- collection routes -----------------------------------------
|
|
878
|
+
|
|
879
|
+
fn domain() -> Value {
|
|
880
|
+
json!({
|
|
881
|
+
"name": "SampleApp",
|
|
882
|
+
"aggregates": [
|
|
883
|
+
{
|
|
884
|
+
"name": "Client",
|
|
885
|
+
"identified_by": ["reference.value"],
|
|
886
|
+
"attributes": [
|
|
887
|
+
{"name": "reference", "type": "ClientReference", "list": false, "optional": false},
|
|
888
|
+
{"name": "name", "type": "ClientName", "list": false, "optional": false},
|
|
889
|
+
{"name": "owner", "type": "Reference<Member>", "list": false, "optional": true},
|
|
890
|
+
{"name": "fee", "type": "Money", "list": false, "optional": true}
|
|
891
|
+
],
|
|
892
|
+
"value_objects": [
|
|
893
|
+
{"name": "ClientReference", "attributes": [{"name": "value", "type": "String"}], "closed_set": false, "members": []},
|
|
894
|
+
{"name": "ClientName", "attributes": [{"name": "value", "type": "String"}], "closed_set": false, "members": []},
|
|
895
|
+
{"name": "Money", "attributes": [{"name": "cents", "type": "Integer"}, {"name": "currency", "type": "String"}],
|
|
896
|
+
"closed_set": false, "members": []}
|
|
897
|
+
],
|
|
898
|
+
"entities": [], "commands": [],
|
|
899
|
+
"queries": [
|
|
900
|
+
{"name": "Active", "attributes": []},
|
|
901
|
+
{"name": "ByOwner", "attributes": [{"name": "owner", "type": "Reference<Member>", "list": false, "optional": false}]},
|
|
902
|
+
{"name": "ByName", "attributes": [{"name": "name", "type": "ClientName", "list": false, "optional": false}]},
|
|
903
|
+
{"name": "Since", "attributes": [{"name": "year", "type": "Integer", "list": false, "optional": true}]}
|
|
904
|
+
],
|
|
905
|
+
"lifecycle": {"field": "status", "default": "prospect",
|
|
906
|
+
"transitions": [{"command": "Activate", "to_state": "active", "from_state": "prospect"}]}
|
|
907
|
+
}
|
|
908
|
+
]
|
|
909
|
+
})
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
fn client_aggregate() -> Value {
|
|
913
|
+
domain()["aggregates"][0].clone()
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
fn params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
|
|
917
|
+
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
#[test]
|
|
921
|
+
fn a_collection_resolves_through_the_key_the_ui_schema_advertises() {
|
|
922
|
+
let domain = domain();
|
|
923
|
+
assert!(resolve_collection(&domain, &json!({}), "clients").is_ok());
|
|
924
|
+
|
|
925
|
+
let renamed = json!({"collections": {"Client": {"key": "accounts"}}});
|
|
926
|
+
assert!(resolve_collection(&domain, &renamed, "accounts").is_ok());
|
|
927
|
+
// The derived key stops working once config renames it — the
|
|
928
|
+
// same single answer `/api/ui-schema` gives the client.
|
|
929
|
+
assert!(resolve_collection(&domain, &renamed, "clients").is_err());
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
#[test]
|
|
933
|
+
fn an_unknown_collection_is_the_ruby_engines_own_not_found_message() {
|
|
934
|
+
let refusal = resolve_collection(&domain(), &json!({}), "nope").expect_err("unknown collections refuse");
|
|
935
|
+
|
|
936
|
+
assert_eq!(refusal["statusCode"], 404);
|
|
937
|
+
assert_eq!(body(&refusal), json!({"error": "NotFound", "message": "no such collection: nope"}));
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
#[test]
|
|
941
|
+
fn a_record_carries_its_id_beside_its_own_fields() {
|
|
942
|
+
assert_eq!(
|
|
943
|
+
with_id("acme", &json!({"name": {"value": "Acme"}})),
|
|
944
|
+
json!({"name": {"value": "Acme"}, "id": "acme"})
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// ---- ?query= ----------------------------------------------------
|
|
949
|
+
|
|
950
|
+
#[test]
|
|
951
|
+
fn a_reference_or_primitive_query_argument_rides_bare_and_a_value_object_wraps() {
|
|
952
|
+
let aggregate = client_aggregate();
|
|
953
|
+
let by_owner = find_query(&aggregate, "ByOwner").expect("declared");
|
|
954
|
+
let by_name = find_query(&aggregate, "ByName").expect("declared");
|
|
955
|
+
|
|
956
|
+
assert_eq!(
|
|
957
|
+
query_args_from_params(&aggregate, by_owner, ¶ms(&[("owner", "chris@example.com")])),
|
|
958
|
+
Some(json!({"owner": "chris@example.com"}))
|
|
959
|
+
);
|
|
960
|
+
assert_eq!(
|
|
961
|
+
query_args_from_params(&aggregate, by_name, ¶ms(&[("name", "Acme")])),
|
|
962
|
+
Some(json!({"name": {"value": "Acme"}}))
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
#[test]
|
|
967
|
+
fn a_missing_required_query_argument_falls_all_the_way_back_to_all_rather_than_dispatching() {
|
|
968
|
+
let aggregate = client_aggregate();
|
|
969
|
+
let by_owner = find_query(&aggregate, "ByOwner").expect("declared");
|
|
970
|
+
|
|
971
|
+
assert_eq!(query_args_from_params(&aggregate, by_owner, ¶ms(&[])), None);
|
|
972
|
+
// An empty param is the same as an absent one, matching Ruby's
|
|
973
|
+
// own `raw.nil? || raw.empty?`.
|
|
974
|
+
assert_eq!(query_args_from_params(&aggregate, by_owner, ¶ms(&[("owner", "")])), None);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
#[test]
|
|
978
|
+
fn an_optional_query_argument_is_simply_left_out_when_nothing_supplies_it() {
|
|
979
|
+
let aggregate = client_aggregate();
|
|
980
|
+
let since = find_query(&aggregate, "Since").expect("declared");
|
|
981
|
+
|
|
982
|
+
assert_eq!(query_args_from_params(&aggregate, since, ¶ms(&[])), Some(json!({})));
|
|
983
|
+
assert_eq!(query_args_from_params(&aggregate, since, ¶ms(&[("year", "2026")])), Some(json!({"year": "2026"})));
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
#[test]
|
|
987
|
+
fn a_no_arg_query_dispatches_with_an_empty_argument_hash() {
|
|
988
|
+
let aggregate = client_aggregate();
|
|
989
|
+
let active = find_query(&aggregate, "Active").expect("declared");
|
|
990
|
+
|
|
991
|
+
assert_eq!(query_args_from_params(&aggregate, active, ¶ms(&[])), Some(json!({})));
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
#[test]
|
|
995
|
+
fn an_unknown_query_name_is_simply_not_found_so_the_caller_falls_back_to_all() {
|
|
996
|
+
assert!(find_query(&client_aggregate(), "NoSuchQuery").is_none());
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// ---- ?sort= -----------------------------------------------------
|
|
1000
|
+
|
|
1001
|
+
#[test]
|
|
1002
|
+
fn a_sortable_column_of_a_pushable_shape_sorts_and_anything_else_falls_back() {
|
|
1003
|
+
let domain = domain();
|
|
1004
|
+
let aggregate = client_aggregate();
|
|
1005
|
+
let spec = resolve_sort(&domain, &json!({}), "clients", &aggregate, ¶ms(&[("sort", "name")]));
|
|
1006
|
+
let spec = spec.expect("a text_value column is sortable");
|
|
1007
|
+
assert_eq!(spec.field, "name");
|
|
1008
|
+
assert!(!spec.descending);
|
|
1009
|
+
|
|
1010
|
+
// A reference column is deliberately excluded — it would need a
|
|
1011
|
+
// cross-aggregate join no order clause wires.
|
|
1012
|
+
assert!(resolve_sort(&domain, &json!({}), "clients", &aggregate, ¶ms(&[("sort", "owner")])).is_none());
|
|
1013
|
+
// …as is a column config explicitly opts out of.
|
|
1014
|
+
let opted_out = json!({"collections": {"Client": {"columns": [{"field": "name", "sortable": false}]}}});
|
|
1015
|
+
assert!(resolve_sort(&domain, &opted_out, "clients", &aggregate, ¶ms(&[("sort", "name")])).is_none());
|
|
1016
|
+
// …and a column key that isn't in this collection at all.
|
|
1017
|
+
assert!(resolve_sort(&domain, &json!({}), "clients", &aggregate, ¶ms(&[("sort", "nope")])).is_none());
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
#[test]
|
|
1021
|
+
fn sorting_by_the_state_column_sorts_by_whatever_the_lifecycle_field_is_really_called() {
|
|
1022
|
+
let domain = domain();
|
|
1023
|
+
let aggregate = client_aggregate();
|
|
1024
|
+
let spec = resolve_sort(&domain, &json!({}), "clients", &aggregate, ¶ms(&[("sort", "__state__"), ("direction", "desc")]))
|
|
1025
|
+
.expect("the derived state pill is sortable");
|
|
1026
|
+
|
|
1027
|
+
assert_eq!(spec.field, "status");
|
|
1028
|
+
assert!(spec.descending);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
#[test]
|
|
1032
|
+
fn records_sort_by_id_when_nothing_asks_for_an_order() {
|
|
1033
|
+
let mut records = vec![
|
|
1034
|
+
("b".to_string(), json!({"name": {"value": "Zed"}})),
|
|
1035
|
+
("a".to_string(), json!({"name": {"value": "Ann"}})),
|
|
1036
|
+
];
|
|
1037
|
+
sort_records(&mut records, &None);
|
|
1038
|
+
|
|
1039
|
+
assert_eq!(records.iter().map(|(id, _)| id.as_str()).collect::<Vec<&str>>(), vec!["a", "b"]);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
#[test]
|
|
1043
|
+
fn sorting_drills_through_a_single_attribute_value_object_and_through_money_to_its_cents() {
|
|
1044
|
+
let mut records = vec![
|
|
1045
|
+
("c".to_string(), json!({"name": {"value": "Cara"}, "fee": {"cents": 300, "currency": "USD"}})),
|
|
1046
|
+
("a".to_string(), json!({"name": {"value": "Ann"}, "fee": {"cents": 1000, "currency": "USD"}})),
|
|
1047
|
+
];
|
|
1048
|
+
|
|
1049
|
+
sort_records(&mut records, &Some(SortSpec { field: "name".to_string(), descending: false }));
|
|
1050
|
+
assert_eq!(records[0].0, "a");
|
|
1051
|
+
|
|
1052
|
+
// Money sorts numerically by cents — 300 before 1000, which a
|
|
1053
|
+
// string comparison would get backwards.
|
|
1054
|
+
sort_records(&mut records, &Some(SortSpec { field: "fee".to_string(), descending: false }));
|
|
1055
|
+
assert_eq!(records.iter().map(|(id, _)| id.as_str()).collect::<Vec<&str>>(), vec!["c", "a"]);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
#[test]
|
|
1059
|
+
fn an_unset_field_sorts_last_ascending_and_first_descending_the_way_postgres_orders_nulls() {
|
|
1060
|
+
let mut records = vec![
|
|
1061
|
+
("missing".to_string(), json!({"name": null})),
|
|
1062
|
+
("ann".to_string(), json!({"name": {"value": "Ann"}})),
|
|
1063
|
+
];
|
|
1064
|
+
|
|
1065
|
+
sort_records(&mut records, &Some(SortSpec { field: "name".to_string(), descending: false }));
|
|
1066
|
+
assert_eq!(records.iter().map(|(id, _)| id.as_str()).collect::<Vec<&str>>(), vec!["ann", "missing"]);
|
|
1067
|
+
|
|
1068
|
+
sort_records(&mut records, &Some(SortSpec { field: "name".to_string(), descending: true }));
|
|
1069
|
+
assert_eq!(records.iter().map(|(id, _)| id.as_str()).collect::<Vec<&str>>(), vec!["missing", "ann"]);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
#[test]
|
|
1073
|
+
fn a_record_that_does_not_exist_refuses_with_json_doors_own_wording() {
|
|
1074
|
+
// `JSON_DOOR.find!`'s own message, `id` inspected the way Ruby
|
|
1075
|
+
// inspects it.
|
|
1076
|
+
let refusal = not_found(&format!("no {} found for id {:?}", "Client", "acme"));
|
|
1077
|
+
assert_eq!(body(&refusal), json!({"error": "NotFound", "message": "no Client found for id \"acme\""}));
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// ---- write routes ------------------------------------------------
|
|
1081
|
+
|
|
1082
|
+
#[test]
|
|
1083
|
+
fn an_empty_body_is_an_empty_argument_hash_and_anything_unparseable_refuses() {
|
|
1084
|
+
assert_eq!(parsed_body(""), Ok(json!({})));
|
|
1085
|
+
assert_eq!(parsed_body(" "), Ok(json!({})));
|
|
1086
|
+
assert_eq!(parsed_body(r#"{"name":{"value":"Acme"}}"#), Ok(json!({"name": {"value": "Acme"}})));
|
|
1087
|
+
|
|
1088
|
+
let refusal = parsed_body("not json").expect_err("a malformed body refuses");
|
|
1089
|
+
assert_eq!(refusal["statusCode"], 400);
|
|
1090
|
+
assert_eq!(body(&refusal), json!({"error": "MalformedBody", "message": "request body is not valid JSON"}));
|
|
1091
|
+
// A body that parses but isn't an object has no arguments to
|
|
1092
|
+
// splat — refused the same way rather than dispatched.
|
|
1093
|
+
assert!(parsed_body("[1,2,3]").is_err());
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
#[test]
|
|
1097
|
+
fn a_command_name_matches_the_snake_case_spelling_the_ui_schema_handed_the_client() {
|
|
1098
|
+
let aggregate = json!({
|
|
1099
|
+
"name": "RecurringPayment",
|
|
1100
|
+
"commands": [
|
|
1101
|
+
{"name": "Schedule", "references": null, "attributes": []},
|
|
1102
|
+
{"name": "AdvanceCycle", "references": "RecurringPayment", "attributes": []}
|
|
1103
|
+
]
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
assert_eq!(command_name(dispatchable_command(&aggregate, "advance_cycle").expect("declared")), "AdvanceCycle");
|
|
1107
|
+
// The creating command is reachable through POST /api/:coll,
|
|
1108
|
+
// never here — accepting it would pass this gate and then fail
|
|
1109
|
+
// as something far less legible.
|
|
1110
|
+
assert!(dispatchable_command(&aggregate, "schedule").is_none());
|
|
1111
|
+
assert!(dispatchable_command(&aggregate, "nope").is_none());
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
#[test]
|
|
1115
|
+
fn a_refused_command_carries_the_kernels_own_refusal_class_and_message() {
|
|
1116
|
+
let result = json!({"refusals": [
|
|
1117
|
+
{"verb": "X.Y", "error": "an earlier one", "kind": "AlreadyExists"},
|
|
1118
|
+
{"verb": "X.Y", "error": "a proposal is only accepted once", "kind": "LifecycleRefused"}
|
|
1119
|
+
]});
|
|
1120
|
+
|
|
1121
|
+
let refusal = domain_refusal(&result);
|
|
1122
|
+
assert_eq!(refusal["statusCode"], 422);
|
|
1123
|
+
// The last refusal — `handle` replays the whole rehydrated
|
|
1124
|
+
// history, so everything before this call's own step already
|
|
1125
|
+
// succeeded once.
|
|
1126
|
+
assert_eq!(
|
|
1127
|
+
body(&refusal),
|
|
1128
|
+
json!({"error": "LifecycleRefused", "message": "a proposal is only accepted once"})
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
#[test]
|
|
1133
|
+
fn the_created_id_is_this_commands_own_target_never_a_reactions() {
|
|
1134
|
+
let result = json!({"mutations": [
|
|
1135
|
+
[{"aggregate": "Client", "id": "earlier"}],
|
|
1136
|
+
[{"aggregate": "Proposal", "id": "P-004"}, {"aggregate": "Engagement", "id": "a-policy-fired"}]
|
|
1137
|
+
]});
|
|
1138
|
+
|
|
1139
|
+
assert_eq!(created_id(&result), Some("P-004".to_string()));
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// ---- identity minting --------------------------------------------
|
|
1143
|
+
|
|
1144
|
+
fn client_with_identity(identity: Value) -> (Value, Value) {
|
|
1145
|
+
let aggregate = json!({
|
|
1146
|
+
"name": "Client",
|
|
1147
|
+
"identified_by": ["reference.value"],
|
|
1148
|
+
"attributes": [
|
|
1149
|
+
{"name": "reference", "type": "ClientReference", "list": false, "optional": false},
|
|
1150
|
+
{"name": "name", "type": "ClientName", "list": false, "optional": false},
|
|
1151
|
+
{"name": "owner", "type": "Reference<Member>", "list": false, "optional": true}
|
|
1152
|
+
],
|
|
1153
|
+
"value_objects": [
|
|
1154
|
+
{"name": "ClientReference", "attributes": [{"name": "value", "type": "String"}], "closed_set": false, "members": []},
|
|
1155
|
+
{"name": "ClientName", "attributes": [{"name": "value", "type": "String"}], "closed_set": false, "members": []}
|
|
1156
|
+
],
|
|
1157
|
+
"entities": [], "commands": [], "queries": [], "lifecycle": null
|
|
1158
|
+
});
|
|
1159
|
+
let presentation = json!({"collections": {"Client": {"identity": identity}}});
|
|
1160
|
+
(aggregate, presentation)
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
#[test]
|
|
1164
|
+
fn a_slug_identity_is_minted_from_another_submitted_field() {
|
|
1165
|
+
let (aggregate, presentation) = client_with_identity(json!({"field": "reference", "strategy": "slug", "source": "name"}));
|
|
1166
|
+
let mut args = json!({"name": {"value": "Acme Corp."}});
|
|
1167
|
+
|
|
1168
|
+
apply_identity(&presentation, &aggregate, &mut args, 0).expect("slugging never refuses");
|
|
1169
|
+
|
|
1170
|
+
assert_eq!(args["reference"], json!({"value": "acme-corp"}));
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
#[test]
|
|
1174
|
+
fn a_sequence_identity_counts_the_records_already_there_and_pads_to_the_configured_width() {
|
|
1175
|
+
let (aggregate, presentation) =
|
|
1176
|
+
client_with_identity(json!({"field": "reference", "strategy": "sequence", "prefix": "C-", "pad": 4}));
|
|
1177
|
+
let mut args = json!({"name": {"value": "Acme"}});
|
|
1178
|
+
|
|
1179
|
+
apply_identity(&presentation, &aggregate, &mut args, 41).expect("sequencing never refuses");
|
|
1180
|
+
|
|
1181
|
+
assert_eq!(args["reference"], json!({"value": "C-0042"}));
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
#[test]
|
|
1185
|
+
fn an_identity_the_caller_supplied_is_left_exactly_as_it_came() {
|
|
1186
|
+
let (aggregate, presentation) = client_with_identity(json!({"field": "reference", "strategy": "slug", "source": "name"}));
|
|
1187
|
+
let mut args = json!({"reference": {"value": "chosen-by-hand"}, "name": {"value": "Acme"}});
|
|
1188
|
+
|
|
1189
|
+
apply_identity(&presentation, &aggregate, &mut args, 0).expect("supplied identities never refuse");
|
|
1190
|
+
|
|
1191
|
+
assert_eq!(args["reference"], json!({"value": "chosen-by-hand"}));
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
#[test]
|
|
1195
|
+
fn an_identity_rule_naming_a_reference_field_mints_nothing_rather_than_guessing_a_wire_shape() {
|
|
1196
|
+
let (aggregate, presentation) = client_with_identity(json!({"field": "owner", "strategy": "slug", "source": "name"}));
|
|
1197
|
+
let mut args = json!({"name": {"value": "Acme"}});
|
|
1198
|
+
|
|
1199
|
+
apply_identity(&presentation, &aggregate, &mut args, 0).expect("no refusal");
|
|
1200
|
+
|
|
1201
|
+
assert!(args.get("owner").is_none(), "{args}");
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Refuses by name rather than dispatching without the field and
|
|
1205
|
+
// surfacing a confusing "absent argument" from the kernel.
|
|
1206
|
+
#[test]
|
|
1207
|
+
fn a_port_identity_strategy_refuses_in_its_own_words_rather_than_dispatching_without_one() {
|
|
1208
|
+
let (aggregate, presentation) = client_with_identity(json!({"field": "reference", "strategy": "port"}));
|
|
1209
|
+
let mut args = json!({"name": {"value": "Acme"}});
|
|
1210
|
+
|
|
1211
|
+
let refusal = apply_identity(&presentation, &aggregate, &mut args, 0).expect_err("the port strategy refuses");
|
|
1212
|
+
|
|
1213
|
+
assert_eq!(refusal["statusCode"], 501);
|
|
1214
|
+
assert!(
|
|
1215
|
+
body(&refusal)["message"].as_str().expect("a message").contains("identity_assignment"),
|
|
1216
|
+
"{refusal}"
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
#[test]
|
|
1221
|
+
fn slugify_matches_the_console_engines_own_rules() {
|
|
1222
|
+
assert_eq!(slugify("Acme Corp."), "acme-corp");
|
|
1223
|
+
assert_eq!(slugify(" Hello, World! "), "hello-world");
|
|
1224
|
+
assert_eq!(slugify("...."), "record");
|
|
1225
|
+
assert_eq!(slugify(""), "record");
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// ---- preconditions ------------------------------------------------
|
|
1229
|
+
|
|
1230
|
+
fn precondition_domain() -> Value {
|
|
1231
|
+
json!({
|
|
1232
|
+
"name": "SampleApp",
|
|
1233
|
+
"aggregates": [
|
|
1234
|
+
{
|
|
1235
|
+
"name": "Contract",
|
|
1236
|
+
"identified_by": ["number.value"],
|
|
1237
|
+
"attributes": [
|
|
1238
|
+
{"name": "number", "type": "ContractNumber", "list": false, "optional": false},
|
|
1239
|
+
{"name": "proposal_id", "type": "Reference<Proposal>", "list": false, "optional": false}
|
|
1240
|
+
],
|
|
1241
|
+
"value_objects": [{"name": "ContractNumber", "attributes": [{"name": "value", "type": "String"}],
|
|
1242
|
+
"closed_set": false, "members": []}],
|
|
1243
|
+
"entities": [], "commands": [], "queries": [], "lifecycle": null
|
|
1244
|
+
},
|
|
1245
|
+
{
|
|
1246
|
+
"name": "Proposal",
|
|
1247
|
+
"identified_by": ["number.value"],
|
|
1248
|
+
"attributes": [], "value_objects": [], "entities": [], "commands": [], "queries": [],
|
|
1249
|
+
"lifecycle": {"field": "status", "default": "drafted", "transitions": []}
|
|
1250
|
+
}
|
|
1251
|
+
]
|
|
1252
|
+
})
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
fn contract() -> Value {
|
|
1256
|
+
precondition_domain()["aggregates"][0].clone()
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
#[test]
|
|
1260
|
+
fn a_precondition_passes_when_the_target_is_already_in_the_named_state() {
|
|
1261
|
+
let instances = json!({"SampleApp::Proposal#P-001": {"status": "accepted"}});
|
|
1262
|
+
let rule = json!({"field": "proposal_id", "state": "accepted"});
|
|
1263
|
+
|
|
1264
|
+
assert!(check_precondition(&precondition_domain(), &contract(), &rule, &json!({"proposal_id": "P-001"}), &instances).is_ok());
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
#[test]
|
|
1268
|
+
fn a_precondition_refuses_when_the_target_is_in_some_other_state() {
|
|
1269
|
+
let instances = json!({"SampleApp::Proposal#P-001": {"status": "sent"}});
|
|
1270
|
+
let rule = json!({"field": "proposal_id", "state": "accepted"});
|
|
1271
|
+
|
|
1272
|
+
let refusal = check_precondition(&precondition_domain(), &contract(), &rule, &json!({"proposal_id": "P-001"}), &instances)
|
|
1273
|
+
.expect_err("a proposal that isn't accepted refuses");
|
|
1274
|
+
|
|
1275
|
+
assert_eq!(refusal["statusCode"], 422);
|
|
1276
|
+
assert_eq!(
|
|
1277
|
+
body(&refusal),
|
|
1278
|
+
json!({"error": "PreconditionFailed", "message": "Proposal is at sent, not accepted"})
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
#[test]
|
|
1283
|
+
fn a_precondition_uses_its_own_configured_message_when_it_has_one() {
|
|
1284
|
+
let rule = json!({"field": "proposal_id", "state": "accepted", "message": "the proposal hasn't been accepted yet"});
|
|
1285
|
+
let instances = json!({"SampleApp::Proposal#P-001": {"status": "sent"}});
|
|
1286
|
+
|
|
1287
|
+
let refusal = check_precondition(&precondition_domain(), &contract(), &rule, &json!({"proposal_id": "P-001"}), &instances)
|
|
1288
|
+
.expect_err("refuses");
|
|
1289
|
+
|
|
1290
|
+
assert_eq!(body(&refusal)["message"], "the proposal hasn't been accepted yet");
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
#[test]
|
|
1294
|
+
fn a_precondition_refuses_a_reference_to_a_record_that_does_not_exist_at_all() {
|
|
1295
|
+
let rule = json!({"field": "proposal_id", "state": "accepted"});
|
|
1296
|
+
|
|
1297
|
+
let refusal = check_precondition(&precondition_domain(), &contract(), &rule, &json!({"proposal_id": "ghost"}), &json!({}))
|
|
1298
|
+
.expect_err("refuses");
|
|
1299
|
+
|
|
1300
|
+
assert_eq!(body(&refusal), json!({"error": "PreconditionFailed", "message": "no proposal with that id"}));
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
#[test]
|
|
1304
|
+
fn a_precondition_says_nothing_about_a_reference_the_caller_left_out() {
|
|
1305
|
+
// A missing required reference is the command's own validation
|
|
1306
|
+
// to refuse, in the domain's own words — not this check's.
|
|
1307
|
+
let rule = json!({"field": "proposal_id", "state": "accepted"});
|
|
1308
|
+
|
|
1309
|
+
assert!(check_precondition(&precondition_domain(), &contract(), &rule, &json!({}), &json!({})).is_ok());
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
#[test]
|
|
1313
|
+
fn a_precondition_naming_a_field_that_is_not_a_reference_simply_does_not_fire() {
|
|
1314
|
+
let rule = json!({"field": "number", "state": "accepted"});
|
|
1315
|
+
|
|
1316
|
+
assert!(check_precondition(&precondition_domain(), &contract(), &rule, &json!({"number": "C-001"}), &json!({})).is_ok());
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// ---- both write routes, end to end --------------------------------
|
|
1320
|
+
//
|
|
1321
|
+
// Runs the two POST routes for real against a throwaway Postgres and
|
|
1322
|
+
// the compiled banking kernel, reached through `dispatch.rs`'s own
|
|
1323
|
+
// test helpers. No ConsoleSettings relations, so the collection key
|
|
1324
|
+
// is the derived one ("customers").
|
|
1325
|
+
|
|
1326
|
+
fn banking_ir() -> Value {
|
|
1327
|
+
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist/banking.ir.json");
|
|
1328
|
+
serde_json::from_str(&std::fs::read_to_string(path).expect("hecks build_wasm writes banking.ir.json beside the wasm"))
|
|
1329
|
+
.expect("valid IR")
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/// A fixture whose `.wasm` carries a ConsoleSettings chapter, the
|
|
1333
|
+
/// premise `PUT /api/presentation` needs.
|
|
1334
|
+
fn console_fixture() -> (std::path::PathBuf, Value) {
|
|
1335
|
+
let dist = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist");
|
|
1336
|
+
let ir = serde_json::from_str(
|
|
1337
|
+
&std::fs::read_to_string(dist.join("checkout_fixture.ir.json"))
|
|
1338
|
+
.expect("hecks build_wasm writes checkout_fixture.ir.json beside the wasm"),
|
|
1339
|
+
)
|
|
1340
|
+
.expect("valid IR");
|
|
1341
|
+
(dist.join("checkout_fixture.wasm"), ir)
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/// Every real state styled, since `missing_state_entries` refuses a
|
|
1345
|
+
/// save that leaves one out.
|
|
1346
|
+
fn every_state_styled() -> Value {
|
|
1347
|
+
json!({"states": {
|
|
1348
|
+
"Event": {"open": {"tone": "good"}, "closed": {"tone": "muted"}},
|
|
1349
|
+
"Registration": {"active": {}, "archived": {"tone": "muted"}},
|
|
1350
|
+
"PaymentConnection": {"connected": {}, "enabled": {}, "disconnected": {}, "paused": {}}
|
|
1351
|
+
}})
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
#[tokio::test]
|
|
1355
|
+
async fn the_presentation_config_is_saved_and_read_back_through_the_kernels_own_commands() {
|
|
1356
|
+
let client = crate::dispatch::tests::scratch_db("rust_host_api_presentation_save").await;
|
|
1357
|
+
crate::dispatch::tests::provision_lineage(
|
|
1358
|
+
&*client.lock().await,
|
|
1359
|
+
"CheckoutFixture",
|
|
1360
|
+
1,
|
|
1361
|
+
&["Event", "Registration", "StateStyle", "Collection", "Overview"],
|
|
1362
|
+
)
|
|
1363
|
+
.await;
|
|
1364
|
+
let (wasm, ir) = console_fixture();
|
|
1365
|
+
let lineage = LineageConfig { domain: "CheckoutFixture".to_string(), era: Some(1), mirrored: None };
|
|
1366
|
+
let invoker = crate::lambda_client::NeverInvoker;
|
|
1367
|
+
|
|
1368
|
+
// Nothing saved yet — every section empty, the same answer the
|
|
1369
|
+
// Ruby engine gives a console that has never saved.
|
|
1370
|
+
let before = presentation::load(&client, &wasm, &lineage).await.expect("a config");
|
|
1371
|
+
assert_eq!(before, json!({"states": {}, "collections": {}, "overview": {}}));
|
|
1372
|
+
|
|
1373
|
+
let mut submitted = every_state_styled();
|
|
1374
|
+
submitted["states"]["Event"]["open"] = json!({"tone": "good", "attention": true, "label": "Open for signups"});
|
|
1375
|
+
submitted["collections"] = json!({
|
|
1376
|
+
"Event": {
|
|
1377
|
+
"label": "Sessions",
|
|
1378
|
+
"nav_order": 1,
|
|
1379
|
+
"columns": ["slug", {"field": "name", "sortable": true, "head": "Title"}, "__state__"],
|
|
1380
|
+
"detail_fields": [{"field": "price", "display": "mono"}],
|
|
1381
|
+
"field_formats": {"capacity": "percent"},
|
|
1382
|
+
"identity": {"field": "slug", "strategy": "slug", "source": "name", "pad": 3},
|
|
1383
|
+
"noun_sing": "session"
|
|
1384
|
+
}
|
|
1385
|
+
});
|
|
1386
|
+
submitted["overview"] = json!({"stats": [{"label": "Open", "collection": "events",
|
|
1387
|
+
"where": {"state": "open"}}]});
|
|
1388
|
+
|
|
1389
|
+
let saved = presentation_save(&ir, &submitted.to_string(), &client, &wasm, &lineage, &invoker).await;
|
|
1390
|
+
assert_eq!(saved["statusCode"], 200, "{saved}");
|
|
1391
|
+
let stored = body(&saved);
|
|
1392
|
+
|
|
1393
|
+
// The response is the reloaded config, not the submitted one —
|
|
1394
|
+
// app.rb returns `json(PresentationConfig.load)` after a save.
|
|
1395
|
+
assert_eq!(stored["states"]["Event"]["open"]["tone"], "good");
|
|
1396
|
+
assert_eq!(stored["states"]["Event"]["open"]["attention"], json!(true));
|
|
1397
|
+
// A field this chapter does not model individually still
|
|
1398
|
+
// round-trips, through `extra_json`.
|
|
1399
|
+
assert_eq!(stored["states"]["Event"]["open"]["label"], "Open for signups");
|
|
1400
|
+
assert_eq!(stored["states"]["Event"]["closed"]["tone"], "muted");
|
|
1401
|
+
assert_eq!(stored["states"]["Registration"]["archived"]["tone"], "muted");
|
|
1402
|
+
assert_eq!(stored["collections"]["Event"]["label"], "Sessions");
|
|
1403
|
+
assert_eq!(stored["collections"]["Event"]["nav_order"], json!(1));
|
|
1404
|
+
assert_eq!(stored["collections"]["Event"]["noun_sing"], "session");
|
|
1405
|
+
assert_eq!(stored["collections"]["Event"]["columns"][1]["sortable"], json!(true));
|
|
1406
|
+
assert_eq!(stored["collections"]["Event"]["columns"][1]["head"], "Title");
|
|
1407
|
+
assert_eq!(stored["collections"]["Event"]["identity"]["pad"], json!(3));
|
|
1408
|
+
assert_eq!(stored["collections"]["Event"]["field_formats"], json!({"capacity": "percent"}));
|
|
1409
|
+
assert_eq!(stored["overview"]["stats"][0]["where"], json!({"state": "open"}));
|
|
1410
|
+
|
|
1411
|
+
// A fresh read agrees, proving the rows are in the kernel's store.
|
|
1412
|
+
assert_eq!(presentation::load(&client, &wasm, &lineage).await.expect("a config"), stored);
|
|
1413
|
+
|
|
1414
|
+
// Saving again is safe: an existing row is a `Set*`, never a
|
|
1415
|
+
// second `Declare` (which would refuse `AlreadyExists`).
|
|
1416
|
+
let again = presentation_save(&ir, &submitted.to_string(), &client, &wasm, &lineage, &invoker).await;
|
|
1417
|
+
assert_eq!(again["statusCode"], 200, "{again}");
|
|
1418
|
+
assert_eq!(body(&again), stored);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
#[tokio::test]
|
|
1422
|
+
async fn a_config_that_breaks_a_rule_refuses_422_malformed_and_writes_nothing() {
|
|
1423
|
+
let client = crate::dispatch::tests::scratch_db("rust_host_api_presentation_malformed").await;
|
|
1424
|
+
crate::dispatch::tests::provision_lineage(
|
|
1425
|
+
&*client.lock().await,
|
|
1426
|
+
"CheckoutFixture",
|
|
1427
|
+
1,
|
|
1428
|
+
&["Event", "Registration", "StateStyle", "Collection", "Overview"],
|
|
1429
|
+
)
|
|
1430
|
+
.await;
|
|
1431
|
+
let (wasm, ir) = console_fixture();
|
|
1432
|
+
let lineage = LineageConfig { domain: "CheckoutFixture".to_string(), era: Some(1), mirrored: None };
|
|
1433
|
+
let invoker = crate::lambda_client::NeverInvoker;
|
|
1434
|
+
|
|
1435
|
+
let mut bad = every_state_styled();
|
|
1436
|
+
bad["states"]["Event"]["open"] = json!({"tone": "chartreuse"});
|
|
1437
|
+
|
|
1438
|
+
let refused = presentation_save(&ir, &bad.to_string(), &client, &wasm, &lineage, &invoker).await;
|
|
1439
|
+
|
|
1440
|
+
assert_eq!(refused["statusCode"], 422, "{refused}");
|
|
1441
|
+
assert_eq!(body(&refused)["error"], "Malformed");
|
|
1442
|
+
assert_eq!(
|
|
1443
|
+
body(&refused)["message"],
|
|
1444
|
+
"Event.open's tone \"chartreuse\" isn't one of good, warn, danger, muted, accent"
|
|
1445
|
+
);
|
|
1446
|
+
|
|
1447
|
+
// Nothing written: `validate` runs before the first dispatch.
|
|
1448
|
+
assert_eq!(
|
|
1449
|
+
presentation::load(&client, &wasm, &lineage).await.expect("a config"),
|
|
1450
|
+
json!({"states": {}, "collections": {}, "overview": {}})
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
#[tokio::test]
|
|
1455
|
+
async fn a_body_that_is_not_json_at_all_is_a_400_before_anything_is_validated() {
|
|
1456
|
+
let client = crate::dispatch::tests::scratch_db("rust_host_api_presentation_bad_body").await;
|
|
1457
|
+
crate::dispatch::tests::provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event"]).await;
|
|
1458
|
+
let (wasm, ir) = console_fixture();
|
|
1459
|
+
let lineage = LineageConfig { domain: "CheckoutFixture".to_string(), era: Some(1), mirrored: None };
|
|
1460
|
+
|
|
1461
|
+
let refused = presentation_save(
|
|
1462
|
+
&ir,
|
|
1463
|
+
"not json",
|
|
1464
|
+
&client,
|
|
1465
|
+
&wasm,
|
|
1466
|
+
&lineage,
|
|
1467
|
+
&crate::lambda_client::NeverInvoker,
|
|
1468
|
+
)
|
|
1469
|
+
.await;
|
|
1470
|
+
|
|
1471
|
+
assert_eq!(refused["statusCode"], 400, "{refused}");
|
|
1472
|
+
assert_eq!(body(&refused)["error"], "MalformedBody");
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/// Banking's kernel carries no ConsoleSettings chapter, and this
|
|
1476
|
+
/// asks the kernel rather than inferring it from anything else.
|
|
1477
|
+
#[tokio::test]
|
|
1478
|
+
async fn a_host_whose_kernel_has_no_console_settings_chapter_still_refuses_501() {
|
|
1479
|
+
let client = crate::dispatch::tests::scratch_db("rust_host_api_presentation_no_chapter").await;
|
|
1480
|
+
crate::dispatch::tests::provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
1481
|
+
let wasm = crate::dispatch::tests::wasm_path();
|
|
1482
|
+
let ir = banking_ir();
|
|
1483
|
+
let lineage = LineageConfig { domain: "Banking".to_string(), era: Some(1), mirrored: None };
|
|
1484
|
+
|
|
1485
|
+
let refused = presentation_save(
|
|
1486
|
+
&ir,
|
|
1487
|
+
r#"{"states":{}}"#,
|
|
1488
|
+
&client,
|
|
1489
|
+
&wasm,
|
|
1490
|
+
&lineage,
|
|
1491
|
+
&crate::lambda_client::NeverInvoker,
|
|
1492
|
+
)
|
|
1493
|
+
.await;
|
|
1494
|
+
|
|
1495
|
+
assert_eq!(refused["statusCode"], 501, "{refused}");
|
|
1496
|
+
assert_eq!(body(&refused)["error"], "NotImplemented");
|
|
1497
|
+
assert!(
|
|
1498
|
+
body(&refused)["message"].as_str().expect("a message").contains("ConsoleSettings"),
|
|
1499
|
+
"the refusal has to name what is missing: {refused}"
|
|
1500
|
+
);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
#[tokio::test]
|
|
1504
|
+
async fn a_record_is_created_then_commanded_then_read_back_through_the_api_routes() {
|
|
1505
|
+
let client = crate::dispatch::tests::scratch_db("rust_host_api_write_routes").await;
|
|
1506
|
+
crate::dispatch::tests::provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
1507
|
+
let wasm = crate::dispatch::tests::wasm_path();
|
|
1508
|
+
let ir = banking_ir();
|
|
1509
|
+
let lineage = LineageConfig { domain: "Banking".to_string(), era: Some(1), mirrored: None };
|
|
1510
|
+
let invoker = crate::lambda_client::NeverInvoker;
|
|
1511
|
+
|
|
1512
|
+
let created = collection_create(
|
|
1513
|
+
&ir,
|
|
1514
|
+
"customers",
|
|
1515
|
+
r#"{"reference":{"value":"CUST-9001"},"name":{"given":"Ada","family":"Lovelace"},"email":{"address":"ada@example.com"}}"#,
|
|
1516
|
+
&client,
|
|
1517
|
+
&wasm,
|
|
1518
|
+
&lineage,
|
|
1519
|
+
&invoker,
|
|
1520
|
+
)
|
|
1521
|
+
.await;
|
|
1522
|
+
|
|
1523
|
+
assert_eq!(created["statusCode"], 200, "{created}");
|
|
1524
|
+
let record = body(&created);
|
|
1525
|
+
assert_eq!(record["id"], "CUST-9001");
|
|
1526
|
+
assert_eq!(record["reference"]["value"], "CUST-9001");
|
|
1527
|
+
|
|
1528
|
+
// …and it is there to read, both ways.
|
|
1529
|
+
let index = collection_index(&ir, "customers", ¶ms(&[]), &client, &wasm, &lineage).await;
|
|
1530
|
+
assert_eq!(body(&index).as_array().expect("an array").len(), 1);
|
|
1531
|
+
let shown = record_show(&ir, "customers", "CUST-9001", &client, &wasm, &lineage).await;
|
|
1532
|
+
assert_eq!(body(&shown)["id"], "CUST-9001");
|
|
1533
|
+
|
|
1534
|
+
// A command against it, named the snake_case way
|
|
1535
|
+
// /api/ui-schema hands it to the client.
|
|
1536
|
+
let suspended = command_route(
|
|
1537
|
+
&ir,
|
|
1538
|
+
"customers",
|
|
1539
|
+
"CUST-9001",
|
|
1540
|
+
"suspend",
|
|
1541
|
+
r#"{"standing":{"value":"watch"}}"#,
|
|
1542
|
+
&client,
|
|
1543
|
+
&wasm,
|
|
1544
|
+
&lineage,
|
|
1545
|
+
&invoker,
|
|
1546
|
+
)
|
|
1547
|
+
.await;
|
|
1548
|
+
|
|
1549
|
+
assert_eq!(suspended["statusCode"], 200, "{suspended}");
|
|
1550
|
+
assert_eq!(body(&suspended)["status"], "suspended");
|
|
1551
|
+
|
|
1552
|
+
// The same command again is a real domain refusal — 422 in the
|
|
1553
|
+
// Ruby engine's envelope, carrying the kernel's own refusal
|
|
1554
|
+
// class, not a generic error.
|
|
1555
|
+
let again = command_route(
|
|
1556
|
+
&ir,
|
|
1557
|
+
"customers",
|
|
1558
|
+
"CUST-9001",
|
|
1559
|
+
"suspend",
|
|
1560
|
+
r#"{"standing":{"value":"watch"}}"#,
|
|
1561
|
+
&client,
|
|
1562
|
+
&wasm,
|
|
1563
|
+
&lineage,
|
|
1564
|
+
&invoker,
|
|
1565
|
+
)
|
|
1566
|
+
.await;
|
|
1567
|
+
|
|
1568
|
+
assert_eq!(again["statusCode"], 422, "{again}");
|
|
1569
|
+
let refused = body(&again);
|
|
1570
|
+
assert!(!refused["error"].as_str().expect("a refusal class").is_empty(), "{refused}");
|
|
1571
|
+
assert!(!refused["message"].as_str().expect("a message").is_empty(), "{refused}");
|
|
1572
|
+
|
|
1573
|
+
// An unknown command, and an unknown record, both refuse the
|
|
1574
|
+
// way JsonDoor refuses them.
|
|
1575
|
+
let unknown_command =
|
|
1576
|
+
command_route(&ir, "customers", "CUST-9001", "nope", "", &client, &wasm, &lineage, &invoker).await;
|
|
1577
|
+
assert_eq!(unknown_command["statusCode"], 404);
|
|
1578
|
+
assert_eq!(body(&unknown_command)["message"], "Customer declares no command named \"nope\"");
|
|
1579
|
+
|
|
1580
|
+
let unknown_record =
|
|
1581
|
+
command_route(&ir, "customers", "ghost", "suspend", "", &client, &wasm, &lineage, &invoker).await;
|
|
1582
|
+
assert_eq!(unknown_record["statusCode"], 404);
|
|
1583
|
+
assert_eq!(body(&unknown_record)["message"], "no Customer found for id \"ghost\"");
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
#[test]
|
|
1587
|
+
fn the_presentation_write_route_refuses_in_its_own_words() {
|
|
1588
|
+
let response = not_implemented(PRESENTATION_WRITE_REFUSAL);
|
|
1589
|
+
assert_eq!(response["statusCode"], 501);
|
|
1590
|
+
assert_eq!(body(&response)["error"], "NotImplemented");
|
|
1591
|
+
assert!(
|
|
1592
|
+
body(&response)["message"].as_str().expect("a message").contains("ConsoleSettings"),
|
|
1593
|
+
"the refusal has to name what is missing: {response}"
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
}
|