hecks 0.2.0 → 1.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 +5 -5
- data/lib/hecks/adapters/driven/claude_code.adapter +3 -0
- data/lib/hecks/adapters/driven/claude_code.rb +127 -0
- data/lib/hecks/adapters/driven/d1.adapter +12 -0
- data/lib/hecks/adapters/driven/d1.rb +427 -0
- data/lib/hecks/adapters/driven/folder.adapter +3 -0
- data/lib/hecks/adapters/driven/folder.rb +199 -0
- data/lib/hecks/adapters/driven/google_authentication.adapter +3 -0
- data/lib/hecks/adapters/driven/google_authentication.rb +100 -0
- data/lib/hecks/adapters/driven/governance_authorization.adapter +3 -0
- data/lib/hecks/adapters/driven/governance_authorization.rb +101 -0
- data/lib/hecks/adapters/driven/heki/journal.rb +56 -0
- data/lib/hecks/adapters/driven/heki/saga_store.rb +99 -0
- data/lib/hecks/adapters/driven/heki/snapshot.rb +65 -0
- data/lib/hecks/adapters/driven/heki.adapter +4 -0
- data/lib/hecks/adapters/driven/heki.rb +179 -0
- data/lib/hecks/adapters/driven/identity_registry.adapter +3 -0
- data/lib/hecks/adapters/driven/identity_registry.rb +26 -0
- data/lib/hecks/adapters/driven/in_memory_ordering.rb +51 -0
- data/lib/hecks/adapters/driven/lambda/client.rb +63 -0
- data/lib/hecks/adapters/driven/lambda.adapter +4 -0
- data/lib/hecks/adapters/driven/lambda.rb +145 -0
- data/lib/hecks/adapters/driven/memory.adapter +3 -0
- data/lib/hecks/adapters/driven/memory.rb +103 -0
- data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +3 -0
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +28 -0
- data/lib/hecks/adapters/driven/postgres/codec.rb +88 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +207 -0
- data/lib/hecks/adapters/driven/postgres.adapter +5 -0
- data/lib/hecks/adapters/driven/postgres.rb +438 -0
- data/lib/hecks/adapters/driven/postgres_era.adapter +17 -0
- data/lib/hecks/adapters/driven/prism.adapter +3 -0
- data/lib/hecks/adapters/driven/prism.rb +80 -0
- data/lib/hecks/adapters/driven/secure_random_identity.adapter +3 -0
- data/lib/hecks/adapters/driven/secure_random_identity.rb +14 -0
- data/lib/hecks/adapters/driven/sql_query_builder.rb +221 -0
- data/lib/hecks/adapters/driven/sqlite/codec.rb +80 -0
- data/lib/hecks/adapters/driven/sqlite/projection.rb +172 -0
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +192 -0
- data/lib/hecks/adapters/driven/sqlite.adapter +9 -0
- data/lib/hecks/adapters/driven/sqlite.rb +306 -0
- data/lib/hecks/adapters/driven/system_clock.adapter +3 -0
- data/lib/hecks/adapters/driven/system_clock.rb +14 -0
- data/lib/hecks/adapters/driven.rb +54 -0
- data/lib/hecks/adapters.rb +6 -0
- data/lib/hecks/behaviors/dsl.rb +125 -0
- data/lib/hecks/behaviors/expectations.rb +340 -0
- data/lib/hecks/behaviors/ir.rb +31 -0
- data/lib/hecks/behaviors/rspec.rb +42 -0
- data/lib/hecks/behaviors/runner.rb +96 -0
- data/lib/hecks/behaviors.rb +25 -0
- data/lib/hecks/bluebook/aggregate.rb +108 -0
- data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +134 -0
- data/lib/hecks/bluebook/assembly/build.rb +48 -0
- data/lib/hecks/bluebook/assembly/contract.rb +112 -0
- data/lib/hecks/bluebook/assembly/contracts.rb +438 -0
- data/lib/hecks/bluebook/assembly/marks.rb +228 -0
- data/lib/hecks/bluebook/assembly/specializer.rb +70 -0
- data/lib/hecks/bluebook/assembly.rb +91 -0
- data/lib/hecks/bluebook/attribute.rb +96 -0
- data/lib/hecks/bluebook/behaviour/aggregate.rb +83 -0
- data/lib/hecks/bluebook/behaviour/attribute.rb +27 -0
- data/lib/hecks/bluebook/behaviour/chapter.rb +72 -0
- data/lib/hecks/bluebook/behaviour/command.rb +116 -0
- data/lib/hecks/bluebook/behaviour/domain_port.rb +25 -0
- data/lib/hecks/bluebook/behaviour/entity.rb +59 -0
- data/lib/hecks/bluebook/behaviour/hexagon.rb +50 -0
- data/lib/hecks/bluebook/behaviour/lifecycle.rb +68 -0
- data/lib/hecks/bluebook/behaviour/policy.rb +52 -0
- data/lib/hecks/bluebook/behaviour/process_manager.rb +51 -0
- data/lib/hecks/bluebook/behaviour/query.rb +10 -0
- data/lib/hecks/bluebook/behaviour/read_model.rb +29 -0
- data/lib/hecks/bluebook/behaviour/traits.rb +81 -0
- data/lib/hecks/bluebook/behaviour/value_object.rb +33 -0
- data/lib/hecks/bluebook/chapter.rb +78 -0
- data/lib/hecks/bluebook/command.rb +124 -0
- data/lib/hecks/bluebook/domain_port.rb +102 -0
- data/lib/hecks/bluebook/dsl/adapter_builder.rb +34 -0
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +1018 -0
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +348 -0
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +71 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +1087 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +714 -0
- data/lib/hecks/bluebook/dsl/const_shim.rb +81 -0
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +79 -0
- data/lib/hecks/bluebook/dsl/entity_builder.rb +430 -0
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +366 -0
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +163 -0
- data/lib/hecks/bluebook/dsl/identity_declaration.rb +191 -0
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +44 -0
- data/lib/hecks/bluebook/dsl/malformed.rb +7 -0
- data/lib/hecks/bluebook/dsl/policy_builder.rb +135 -0
- data/lib/hecks/bluebook/dsl/port_builder.rb +39 -0
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +142 -0
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +306 -0
- data/lib/hecks/bluebook/dsl/query_builder.rb +113 -0
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +233 -0
- data/lib/hecks/bluebook/dsl/rule_reference.rb +173 -0
- data/lib/hecks/bluebook/dsl/translation_builder.rb +243 -0
- data/lib/hecks/bluebook/dsl/value_object_builder.rb +178 -0
- data/lib/hecks/bluebook/dsl/word_gate.rb +228 -0
- data/lib/hecks/bluebook/dsl/world_builder.rb +117 -0
- data/lib/hecks/bluebook/dsl.rb +45 -0
- data/lib/hecks/bluebook/entity.rb +103 -0
- data/lib/hecks/bluebook/expression/canonical_form.rb +123 -0
- data/lib/hecks/bluebook/expression/evaluator.rb +304 -0
- data/lib/hecks/bluebook/expression/projection.json +218 -0
- data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +233 -0
- data/lib/hecks/bluebook/expression/resolver.rb +781 -0
- data/lib/hecks/bluebook/expression.rb +13 -0
- data/lib/hecks/bluebook/hexagon.rb +60 -0
- data/lib/hecks/bluebook/lifecycle.rb +42 -0
- data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +54 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +621 -0
- data/lib/hecks/bluebook/meta_validator/plan.rb +332 -0
- data/lib/hecks/bluebook/meta_validator/port_judge.rb +51 -0
- data/lib/hecks/bluebook/meta_validator/readings.rb +360 -0
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +351 -0
- data/lib/hecks/bluebook/meta_validator/shapes.rb +266 -0
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +255 -0
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +138 -0
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +78 -0
- data/lib/hecks/bluebook/meta_validator.rb +542 -0
- data/lib/hecks/bluebook/model_check.rb +445 -0
- data/lib/hecks/bluebook/pattern_subset.rb +184 -0
- data/lib/hecks/bluebook/policy.rb +41 -0
- data/lib/hecks/bluebook/process_manager.rb +128 -0
- data/lib/hecks/bluebook/project_discovery.rb +30 -0
- data/lib/hecks/bluebook/project_loader.rb +40 -0
- data/lib/hecks/bluebook/project_register.rb +82 -0
- data/lib/hecks/bluebook/query.rb +61 -0
- data/lib/hecks/bluebook/read_model.rb +109 -0
- data/lib/hecks/bluebook/reference.rb +74 -0
- data/lib/hecks/bluebook/smoke_test.rb +166 -0
- data/lib/hecks/bluebook/synthesizer.rb +95 -0
- data/lib/hecks/bluebook/translation.rb +92 -0
- data/lib/hecks/bluebook/value_object.rb +66 -0
- data/lib/hecks/bluebook.rb +74 -0
- data/lib/hecks/codemod.rb +342 -0
- data/lib/hecks/construct.rb +71 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +219 -0
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +4 -0
- data/lib/hecks/deploy/oidc.json +18 -0
- data/lib/hecks/doc/reference.rb +410 -0
- data/lib/hecks/embryonaut_bluebook.rb +75 -0
- data/lib/hecks/facade/cli_door.rb +119 -0
- data/lib/hecks/facade/cli_runner.rb +190 -0
- data/lib/hecks/facade/command_request.rb +105 -0
- data/lib/hecks/facade/handle.rb +173 -0
- data/lib/hecks/facade/json_door.rb +166 -0
- data/lib/hecks/facade/surface/aggregate_door.rb +185 -0
- data/lib/hecks/facade/surface/chapter.rb +107 -0
- data/lib/hecks/facade/surface.rb +48 -0
- data/lib/hecks/facade.rb +44 -0
- data/lib/hecks/forms/app.rb +341 -0
- data/lib/hecks/forms/command_form_renderer.rb +113 -0
- data/lib/hecks/forms/examples/banking_console.bluebook +3 -0
- data/lib/hecks/forms/field_renderer.rb +177 -0
- data/lib/hecks/forms/field_shape.rb +232 -0
- data/lib/hecks/forms/html.rb +84 -0
- data/lib/hecks/forms/index_renderer.rb +35 -0
- data/lib/hecks/forms/page.rb +157 -0
- data/lib/hecks/forms/params.rb +161 -0
- data/lib/hecks/forms/port_argument.rb +46 -0
- data/lib/hecks/forms/query_form_renderer.rb +114 -0
- data/lib/hecks/forms/record_renderer.rb +119 -0
- data/lib/hecks/forms/record_table.rb +68 -0
- data/lib/hecks/forms/reference_options.rb +30 -0
- data/lib/hecks/forms/value_object_shape.rb +46 -0
- data/lib/hecks/forms.rb +54 -0
- data/lib/hecks/fqn.rb +94 -0
- data/lib/hecks/framework/bluebook/compliance.bluebook +1 -0
- data/lib/hecks/framework/bluebook/console_settings.bluebook +489 -0
- data/lib/hecks/framework/bluebook/framework.hecksagon +32 -0
- data/lib/hecks/framework/bluebook/governance.bluebook +145 -0
- data/lib/hecks/framework/bluebook/identity.bluebook +90 -0
- data/lib/hecks/framework/oidc.json +39 -0
- data/lib/hecks/framework.rb +90 -0
- data/lib/hecks/freezer.rb +67 -0
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
- data/lib/hecks/fuzzing/invalid_value_generator.rb +86 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +286 -0
- data/lib/hecks/fuzzing/properties.rb +1192 -0
- data/lib/hecks/fuzzing/replay.rb +668 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +93 -0
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +80 -0
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +96 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +250 -0
- data/lib/hecks/fuzzing/sequence_generator.rb +131 -0
- data/lib/hecks/fuzzing/value_generator.rb +200 -0
- data/lib/hecks/fuzzing.rb +12 -0
- data/lib/hecks/grammar/evolve.rb +324 -0
- data/lib/hecks/grammar/expression.bluebook +420 -0
- data/lib/hecks/grammar/expression_operators.json +1648 -0
- data/lib/hecks/grammar/grammar.hecksagon +20 -0
- data/lib/hecks/grammar/oidc.json +69 -0
- data/lib/hecks/grammar/translation.bluebook +203 -0
- data/lib/hecks/grammar.rb +178 -0
- data/lib/hecks/ir.rb +126 -0
- data/lib/hecks/language/adapter.bluebook +116 -0
- data/lib/hecks/language/bluebook/aggregate.bluebook +581 -0
- data/lib/hecks/language/bluebook/attaches/paging.bluebook +76 -0
- data/lib/hecks/language/bluebook/bluebook.bluebook +256 -0
- data/lib/hecks/language/bluebook/bluebook.hecksagon +24 -0
- data/lib/hecks/language/bluebook/command.bluebook +471 -0
- data/lib/hecks/language/bluebook/entity.bluebook +392 -0
- data/lib/hecks/language/bluebook/policy.bluebook +189 -0
- data/lib/hecks/language/bluebook/process_manager.bluebook +381 -0
- data/lib/hecks/language/bluebook/projection.bluebook +267 -0
- data/lib/hecks/language/bluebook/query.bluebook +245 -0
- data/lib/hecks/language/bluebook/shape.bluebook +292 -0
- data/lib/hecks/language/bluebook/syntax.bluebook +448 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +379 -0
- data/lib/hecks/language/hecksagon/adapter_binding.bluebook +51 -0
- data/lib/hecks/language/hecksagon/domain_port.bluebook +76 -0
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +131 -0
- data/lib/hecks/language/hecksagon/port_operation.bluebook +102 -0
- data/lib/hecks/language/oidc.json +333 -0
- data/lib/hecks/language/port.bluebook +120 -0
- data/lib/hecks/language/translation/translation.bluebook +110 -0
- data/lib/hecks/language/translation/translation_aggregate.bluebook +267 -0
- data/lib/hecks/language/world/wiring.bluebook +62 -0
- data/lib/hecks/language/world/world.bluebook +84 -0
- data/lib/hecks/literal.rb +125 -0
- data/lib/hecks/naming.rb +174 -0
- data/lib/hecks/ports/access_control.port +9 -0
- data/lib/hecks/ports/access_control.rb +62 -0
- data/lib/hecks/ports/agent/answers.rb +104 -0
- data/lib/hecks/ports/agent.port +8 -0
- data/lib/hecks/ports/agent.rb +167 -0
- data/lib/hecks/ports/authentication.port +6 -0
- data/lib/hecks/ports/authentication.rb +50 -0
- data/lib/hecks/ports/authorization.port +7 -0
- data/lib/hecks/ports/authorization.rb +62 -0
- data/lib/hecks/ports/clock.port +5 -0
- data/lib/hecks/ports/clock.rb +62 -0
- data/lib/hecks/ports/extraction.port +5 -0
- data/lib/hecks/ports/extraction.rb +37 -0
- data/lib/hecks/ports/identity_assignment.port +5 -0
- data/lib/hecks/ports/identity_assignment.rb +45 -0
- data/lib/hecks/ports/identity_generation.port +5 -0
- data/lib/hecks/ports/identity_generation.rb +49 -0
- data/lib/hecks/ports/identity_resolution.port +5 -0
- data/lib/hecks/ports/identity_resolution.rb +40 -0
- data/lib/hecks/ports/loading.port +4 -0
- data/lib/hecks/ports/loading.rb +13 -0
- data/lib/hecks/ports/persistence/append_only.rb +138 -0
- data/lib/hecks/ports/persistence/binding_policy.rb +56 -0
- data/lib/hecks/ports/persistence/execution.rb +23 -0
- data/lib/hecks/ports/persistence/null_saga_store.rb +25 -0
- data/lib/hecks/ports/persistence/plugin.rb +54 -0
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +169 -0
- data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +128 -0
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +161 -0
- data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +61 -0
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +304 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +171 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +190 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +478 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +166 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +314 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +168 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +170 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +134 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +137 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +89 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +85 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +43 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +132 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +74 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +758 -0
- data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +120 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +31 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +46 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +102 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +46 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +70 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +72 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +120 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +183 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +41 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +28 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +30 -0
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +11 -0
- data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
- data/lib/hecks/ports/persistence/remote_runtime.rb +42 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +30 -0
- data/lib/hecks/ports/persistence.port +4 -0
- data/lib/hecks/ports/persistence.rb +39 -0
- data/lib/hecks/ports/projection.port +4 -0
- data/lib/hecks/ports/projection.rb +96 -0
- data/lib/hecks/ports/query/in_memory.rb +60 -0
- data/lib/hecks/ports/query/ordering.rb +41 -0
- data/lib/hecks/ports/query.rb +36 -0
- data/lib/hecks/ports.rb +27 -0
- data/lib/hecks/projections/diagrams.rb +642 -0
- data/lib/hecks/projections/ir.rb +18 -0
- data/lib/hecks/projections/model/deviations.rb +98 -0
- data/lib/hecks/projections/model.rb +145 -0
- data/lib/hecks/projections/oidc.rb +110 -0
- data/lib/hecks/projections/parser_table.rb +159 -0
- data/lib/hecks/projections/reference.rb +38 -0
- data/lib/hecks/projections/shape.rb +44 -0
- data/lib/hecks/projections/statements.rb +110 -0
- data/lib/hecks/projections/vocabulary.rb +100 -0
- data/lib/hecks/projections.rb +32 -0
- data/lib/hecks/projector/cli_projector.rb +446 -0
- data/lib/hecks/projector/docs_projector.rb +321 -0
- data/lib/hecks/projector/exporter.rb +158 -0
- data/lib/hecks/projector/ir_projector.rb +18 -0
- data/lib/hecks/projector/narrate_projector.rb +243 -0
- data/lib/hecks/projector/target.rb +97 -0
- data/lib/hecks/projector.rb +186 -0
- data/lib/hecks/query_ir.rb +411 -0
- data/lib/hecks/query_specification/common/authorization_spec.rb +9 -0
- data/lib/hecks/query_specification/common/comparators.rb +25 -0
- data/lib/hecks/query_specification/common/comparison.rb +174 -0
- data/lib/hecks/query_specification/common/cursor_spec.rb +9 -0
- data/lib/hecks/query_specification/common/dsl.rb +58 -0
- data/lib/hecks/query_specification/common/inspection_spec.rb +9 -0
- data/lib/hecks/query_specification/common/limit_spec.rb +9 -0
- data/lib/hecks/query_specification/common/null_policy.rb +92 -0
- data/lib/hecks/query_specification/common/null_semantics.rb +11 -0
- data/lib/hecks/query_specification/common/offset_spec.rb +9 -0
- data/lib/hecks/query_specification/common/options.rb +32 -0
- data/lib/hecks/query_specification/common/order_by.rb +9 -0
- data/lib/hecks/query_specification/common/specification.rb +10 -0
- data/lib/hecks/query_specification/common/where_clause.rb +9 -0
- data/lib/hecks/query_specification/field_path.rb +107 -0
- data/lib/hecks/query_specification/hop_path.rb +132 -0
- data/lib/hecks/query_specification/read_model/specification.rb +18 -0
- data/lib/hecks/query_specification.rb +14 -0
- data/lib/hecks/rendering.rb +48 -0
- data/lib/hecks/router/namespace_installer.rb +157 -0
- data/lib/hecks/router.rb +70 -0
- data/lib/hecks/runtime/aggregate_lock.rb +45 -0
- data/lib/hecks/runtime/boot_gates.rb +41 -0
- data/lib/hecks/runtime/caller.rb +66 -0
- data/lib/hecks/runtime/capability_graph.rb +44 -0
- data/lib/hecks/runtime/command_interpreter/argument_gate.rb +135 -0
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +260 -0
- data/lib/hecks/runtime/command_interpreter.rb +497 -0
- data/lib/hecks/runtime/command_rules/admissibility.rb +357 -0
- data/lib/hecks/runtime/command_rules/arithmetic.rb +260 -0
- data/lib/hecks/runtime/command_rules/authorization.rb +65 -0
- data/lib/hecks/runtime/command_rules/emission.rb +34 -0
- data/lib/hecks/runtime/command_rules/references.rb +189 -0
- data/lib/hecks/runtime/command_rules.rb +28 -0
- data/lib/hecks/runtime/dependency_planning.rb +245 -0
- data/lib/hecks/runtime/dispatcher.rb +301 -0
- data/lib/hecks/runtime/entity_element.rb +253 -0
- data/lib/hecks/runtime/entity_interpreter.rb +299 -0
- data/lib/hecks/runtime/errors.rb +122 -0
- data/lib/hecks/runtime/event.rb +51 -0
- data/lib/hecks/runtime/identity.rb +156 -0
- data/lib/hecks/runtime/instance.rb +175 -0
- data/lib/hecks/runtime/interpreting.rb +90 -0
- data/lib/hecks/runtime/loader.rb +184 -0
- data/lib/hecks/runtime/policy_interpreter.rb +366 -0
- data/lib/hecks/runtime/port_operation_interpreter.rb +210 -0
- data/lib/hecks/runtime/query_interpreter.rb +286 -0
- data/lib/hecks/runtime/reaction_invocation.rb +253 -0
- data/lib/hecks/runtime/read_model_interpreter.rb +341 -0
- data/lib/hecks/runtime/rebuild_sweep.rb +74 -0
- data/lib/hecks/runtime/reference_hop.rb +99 -0
- data/lib/hecks/runtime/refusal_wording.rb +126 -0
- data/lib/hecks/runtime/registry/saga_persistence.rb +142 -0
- data/lib/hecks/runtime/registry/verification.rb +246 -0
- data/lib/hecks/runtime/registry.rb +279 -0
- data/lib/hecks/runtime/remote_dispatcher.rb +143 -0
- data/lib/hecks/runtime/routing.rb +96 -0
- data/lib/hecks/runtime/saga_interpreter/correlation.rb +97 -0
- data/lib/hecks/runtime/saga_interpreter.rb +503 -0
- data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
- data/lib/hecks/runtime/tenant_check.rb +84 -0
- data/lib/hecks/runtime/tenant_scope.rb +55 -0
- data/lib/hecks/runtime/value/admission.rb +130 -0
- data/lib/hecks/runtime/value/coercion.rb +565 -0
- data/lib/hecks/runtime/value/invariant_violation.rb +5 -0
- data/lib/hecks/runtime/value.rb +125 -0
- data/lib/hecks/runtime.rb +107 -0
- data/lib/hecks/storehouse.rb +632 -0
- data/lib/hecks/version.rb +16 -0
- data/lib/hecks/vocabulary.rb +218 -0
- data/lib/hecks.rb +123 -6
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
- metadata +412 -222
- data/bin/hecks +0 -7
- data/bin/hecks-package +0 -65
- data/bin/hecks_console +0 -12
- data/bin/hecks_serverless +0 -6
- data/lib/cli/build.rb +0 -14
- data/lib/cli/command_runner.rb +0 -28
- data/lib/cli/console.rb +0 -10
- data/lib/cli/generate.rb +0 -37
- data/lib/cli/hecks-cli.rb +0 -27
- data/lib/cli/test.rb +0 -57
- data/lib/console/commands.rb +0 -8
- data/lib/console/hecks-console.rb +0 -1
- data/lib/packager/README.md +0 -0
- data/lib/packager/app_runner.rb +0 -21
- data/lib/packager/args.rb +0 -26
- data/lib/packager/compatibility/fixnum.rb +0 -6
- data/lib/packager/hecks.rb +0 -39
- data/lib/packager/query_runner.rb +0 -21
- data/lib/packager/resources/Dockerfile +0 -11
- data/lib/packager/resources/app_binary +0 -7
- data/lib/packager/resources/bundle_config +0 -3
- data/lib/packager/resources/traveling-ruby-20150715-2.2.2-linux-x86_64.tar.gz +0 -0
- data/lib/packager/resources/traveling-ruby-20150715-2.2.2-osx.tar.gz +0 -0
- data/lib/serverless/Domain +0 -32
- data/lib/serverless/cli.rb +0 -75
- data/lib/serverless/resources/command_name.js +0 -5
- data/lib/serverless/resources/environment.js +0 -7
- data/lib/serverless/resources/handler.js.tt +0 -28
- data/lib/serverless/resources/run_binary.js +0 -22
- data/lib/serverless/resources/serverless.yml +0 -20
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "rack"
|
|
3
|
+
require_relative "page"
|
|
4
|
+
require_relative "field_shape"
|
|
5
|
+
require_relative "index_renderer"
|
|
6
|
+
require_relative "record_renderer"
|
|
7
|
+
require_relative "command_form_renderer"
|
|
8
|
+
require_relative "query_form_renderer"
|
|
9
|
+
require_relative "params"
|
|
10
|
+
require_relative "../facade/command_request"
|
|
11
|
+
require_relative "../facade/json_door"
|
|
12
|
+
|
|
13
|
+
module Hecks
|
|
14
|
+
module Forms
|
|
15
|
+
# The content-negotiated router: `GET /Banking/Account/Overdrawn.html`
|
|
16
|
+
# renders the query view built in this directory; the identical path
|
|
17
|
+
# with no extension (or `.json`) dispatches the SAME ask through
|
|
18
|
+
# `Runtime::Dispatcher#query` and answers with its raw result — one
|
|
19
|
+
# route, two representations, exactly the "change the file format on
|
|
20
|
+
# the route" mechanism this was built around
|
|
21
|
+
# (docs/command-form-and-query-form-bluebook.md).
|
|
22
|
+
#
|
|
23
|
+
# A plain Rack app (`#call(env)`) — no Sinatra, no Rails. `rack` itself
|
|
24
|
+
# is a LAZY dependency the same way `pg`/`oauth2`/`aws-sdk-lambda` are
|
|
25
|
+
# for their own adapters (see the Gemfile's own comment) : a project
|
|
26
|
+
# that never boots this file never needs it installed.
|
|
27
|
+
class App
|
|
28
|
+
def self.for(registry:, app_name:)
|
|
29
|
+
config = Forms.config(app_name) ||
|
|
30
|
+
raise(ArgumentError, "no app #{app_name.inspect} configured — " \
|
|
31
|
+
"call Hecks::Forms.configure(#{app_name.inspect}) { expose \"...\" } first")
|
|
32
|
+
new(registry: registry, exposed: config.exposes)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(registry:, exposed:, dispatcher: nil)
|
|
36
|
+
@registry = registry
|
|
37
|
+
@exposed = exposed
|
|
38
|
+
@dispatcher = dispatcher || Runtime::Dispatcher.new(registry)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call(env)
|
|
42
|
+
request = Rack::Request.new(env)
|
|
43
|
+
route(request)
|
|
44
|
+
rescue RouteNotFound => e
|
|
45
|
+
respond(404, "text/plain", e.message)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class RouteNotFound < StandardError; end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def route(request)
|
|
53
|
+
segments = request.path_info.split("/").reject(&:empty?)
|
|
54
|
+
return home(request) if segments.empty?
|
|
55
|
+
|
|
56
|
+
domain = segments[0]
|
|
57
|
+
refuse_unexposed(domain)
|
|
58
|
+
chapter = @registry.bluebook(domain) || raise(RouteNotFound, "no domain #{domain.inspect} loaded")
|
|
59
|
+
|
|
60
|
+
return aggregate_route(request, chapter, *split_format(segments[1])) if segments.size == 2
|
|
61
|
+
|
|
62
|
+
return verb_or_record_route(request, chapter, segments[1], *split_format(segments[2])) if segments.size == 3
|
|
63
|
+
|
|
64
|
+
if segments.size == 4
|
|
65
|
+
aggregate = chapter.aggregate(segments[1])
|
|
66
|
+
entity = aggregate&.entities&.find { |candidate| candidate.hecks_name == segments[2] }
|
|
67
|
+
entity_command = entity&.command(split_format(segments[3]).first)
|
|
68
|
+
if entity_command
|
|
69
|
+
raise RouteNotFound,
|
|
70
|
+
"entity command routes are not supported by Forms; use a command door with " \
|
|
71
|
+
"to.aggregate and to.entity"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
respond(404, "text/plain", "no route for #{request.path_info}")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def refuse_unexposed(domain)
|
|
79
|
+
return if @exposed.include?(domain)
|
|
80
|
+
|
|
81
|
+
raise RouteNotFound, "#{domain.inspect} is not exposed by this app — declared chapters: #{@exposed.join(', ')}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# H12 (docs/audits/2026-08-10-main-bug-audit.md) — splitting on the
|
|
85
|
+
# FIRST "." truncated any identity value containing a dot (an email
|
|
86
|
+
# `identified_by { email.address }`, a decimal-ish reference — an
|
|
87
|
+
# aggregate's identity is free-form unless its value object declares
|
|
88
|
+
# a `pattern:`, see S3 in the same audit) at its own first dot, so
|
|
89
|
+
# `reference.value=c.1` 404'd everywhere: detail page, JSON view, and
|
|
90
|
+
# its own index-table link. Only a LITERAL trailing ".html"/".json"
|
|
91
|
+
# now counts as a format — every other dot in the segment is just
|
|
92
|
+
# part of the identity. An identity that itself happens to end in
|
|
93
|
+
# exactly ".html" or ".json" is still ambiguous with a real format
|
|
94
|
+
# suffix (the same tension any extension-based content-negotiation
|
|
95
|
+
# scheme has), but that was already true before this fix and is not
|
|
96
|
+
# this bug.
|
|
97
|
+
def split_format(segment)
|
|
98
|
+
segment = segment.to_s
|
|
99
|
+
return [Regexp.last_match(1), Regexp.last_match(2)] if segment =~ /\A(.*)\.(html|json)\z/
|
|
100
|
+
|
|
101
|
+
[segment, "json"]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ---- home -------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def home(request)
|
|
107
|
+
format = request.params["format"] || "html"
|
|
108
|
+
chapters = @exposed.to_h { |name| [name, @registry.bluebook(name)] }.compact
|
|
109
|
+
return json(200, chapters.transform_values { |c| { aggregates: c.aggregates.map(&:hecks_name) } }) if format != "html"
|
|
110
|
+
|
|
111
|
+
html("Exposed domains", IndexRenderer.render(chapters))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# ---- /:domain/:aggregate.:format ---------------------------------
|
|
115
|
+
|
|
116
|
+
def aggregate_route(request, chapter, aggregate_name, format)
|
|
117
|
+
aggregate = find_aggregate(chapter, aggregate_name)
|
|
118
|
+
return respond(405, "text/plain", "GET only") unless request.get?
|
|
119
|
+
|
|
120
|
+
if format == "html"
|
|
121
|
+
html("#{chapter.name}::#{aggregate.hecks_name}", RecordRenderer.index(registry: @registry, domain: chapter.name, aggregate: aggregate),
|
|
122
|
+
breadcrumbs: [[chapter.name, "/"], [aggregate.hecks_name, nil]])
|
|
123
|
+
else
|
|
124
|
+
instances = @registry.repository(chapter.name, aggregate).all
|
|
125
|
+
# id LAST — see Instance#to_h's own comment: an aggregate free
|
|
126
|
+
# to declare its own attribute literally named `id` has that
|
|
127
|
+
# attribute's own wrapped value sitting in `i.state[:id]`
|
|
128
|
+
# already, which used to silently clobber the correct bare
|
|
129
|
+
# identity when merged first.
|
|
130
|
+
json(200, instances.map { |i| i.state.merge(id: i.id) })
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# ---- /:domain/:aggregate/:verb_or_id.:format ----------------------
|
|
135
|
+
|
|
136
|
+
def verb_or_record_route(request, chapter, aggregate_name, verb_or_id, format)
|
|
137
|
+
aggregate = find_aggregate(chapter, aggregate_name)
|
|
138
|
+
domain = chapter.name
|
|
139
|
+
|
|
140
|
+
# L11 (docs/audits/2026-08-10-main-bug-audit.md) — a record's own
|
|
141
|
+
# id is free-form (S3) and can collide with one of its own
|
|
142
|
+
# aggregate's command/query names ("Close", "Overdrawn", ...).
|
|
143
|
+
# A GET for such an id must still be able to reach that RECORD's
|
|
144
|
+
# own detail page when a record with that literal id actually
|
|
145
|
+
# exists — checking the verb first (the previous order) meant a
|
|
146
|
+
# record unlucky enough to be named after a real verb could never
|
|
147
|
+
# be viewed again. POST never means "view a record" at all
|
|
148
|
+
# (`record_route` only ever answers GET), so command submission
|
|
149
|
+
# there is unambiguous and is left to match the verb first, same
|
|
150
|
+
# as before.
|
|
151
|
+
if request.get? && (instance = @registry.repository(domain, aggregate).find(verb_or_id))
|
|
152
|
+
return record_route(request, domain, aggregate, verb_or_id, format, instance: instance)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if (command = aggregate.command(verb_or_id))
|
|
156
|
+
return command_route(request, domain, aggregate, command, format)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
if (query = aggregate.query(verb_or_id))
|
|
160
|
+
return query_route(request, domain, aggregate, query, format)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
record_route(request, domain, aggregate, verb_or_id, format)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def command_route(request, domain, aggregate, command, format)
|
|
167
|
+
action = "/#{domain}/#{aggregate.hecks_name}/#{command.hecks_name}.html"
|
|
168
|
+
return command_json(request, domain, aggregate, command) if format != "html"
|
|
169
|
+
if request.get?
|
|
170
|
+
return command_form(domain, aggregate, command, action,
|
|
171
|
+
prefill: receiver_compatibility(request.GET, command))
|
|
172
|
+
end
|
|
173
|
+
return respond(405, "text/plain", "GET or POST only") unless request.post?
|
|
174
|
+
|
|
175
|
+
submit_command(request, domain, aggregate, command, action)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def command_form(domain, aggregate, command, action, status: 200, values: nil, error: nil, prefill: {})
|
|
179
|
+
html("#{domain}::#{aggregate.hecks_name}.#{command.hecks_name}",
|
|
180
|
+
CommandFormRenderer.render(registry: @registry, domain: domain, aggregate: aggregate, command: command,
|
|
181
|
+
action: action, values: values, error: error, prefill: prefill),
|
|
182
|
+
breadcrumbs: [[domain, "/"], [aggregate.hecks_name, "/#{domain}/#{aggregate.hecks_name}.html"],
|
|
183
|
+
[command.hecks_name, nil]],
|
|
184
|
+
status: status)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def submit_command(request, domain, aggregate, command, action)
|
|
188
|
+
raw, envelope = submitted_command(request, aggregate, command)
|
|
189
|
+
result = @dispatcher.dispatch("#{domain}::#{aggregate.hecks_name}.#{command.hecks_name}", **envelope)
|
|
190
|
+
# L12 — the id is free-form (S3), so it must be percent-encoded as
|
|
191
|
+
# a path segment here, not just interpolated raw.
|
|
192
|
+
redirect("/#{domain}/#{aggregate.hecks_name}/#{Escape.path(result.id)}.html")
|
|
193
|
+
rescue *Runtime::DOMAIN_REFUSALS, ArgumentError, TypeError, JSON::ParserError => e
|
|
194
|
+
status = e.is_a?(Runtime::NotFound) ? 404 : 422
|
|
195
|
+
command_form(domain, aggregate, command, action, status: status, values: raw, error: e)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def command_json(request, domain, aggregate, command)
|
|
199
|
+
return json(200, command.to_h) if request.get? && request.GET.reject { |k, _| k == "format" }.empty?
|
|
200
|
+
return respond(405, "text/plain", "GET or POST only") unless request.post?
|
|
201
|
+
|
|
202
|
+
_, envelope = submitted_command(request, aggregate, command)
|
|
203
|
+
result = @dispatcher.dispatch("#{domain}::#{aggregate.hecks_name}.#{command.hecks_name}", **envelope)
|
|
204
|
+
# id LAST — same reasoning as the other JSON-serializing call
|
|
205
|
+
# sites in this file (see aggregate_route's own comment).
|
|
206
|
+
json(201, result.state.merge(id: result.id))
|
|
207
|
+
rescue *Runtime::DOMAIN_REFUSALS, ArgumentError, TypeError, JSON::ParserError => e
|
|
208
|
+
status = e.is_a?(Runtime::NotFound) ? 404 : 422
|
|
209
|
+
json(status, { error: e.class.name.split("::").last, message: e.message })
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def query_route(request, domain, aggregate, query, format)
|
|
213
|
+
return respond(405, "text/plain", "GET only") unless request.get?
|
|
214
|
+
|
|
215
|
+
params = request.GET
|
|
216
|
+
asked = params.reject { |k, _| k == "format" }
|
|
217
|
+
return query_json(domain, aggregate, query, asked) if format != "html"
|
|
218
|
+
|
|
219
|
+
query_html(domain, aggregate, query, asked)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def query_html(domain, aggregate, query, asked)
|
|
223
|
+
action = "/#{domain}/#{aggregate.hecks_name}/#{query.hecks_name}.html"
|
|
224
|
+
fields = query.attributes.map { |a| FieldShape.resolve(a, aggregate: aggregate) }
|
|
225
|
+
results, error = asked.empty? ? [nil, nil] : run_query(domain, aggregate, query, fields, asked)
|
|
226
|
+
html("#{domain}::#{aggregate.hecks_name}.#{query.hecks_name}",
|
|
227
|
+
QueryFormRenderer.render(registry: @registry, domain: domain, aggregate: aggregate, query: query,
|
|
228
|
+
action: action, params: asked, results: results, error: error),
|
|
229
|
+
breadcrumbs: [[domain, "/"], [aggregate.hecks_name, "/#{domain}/#{aggregate.hecks_name}.html"],
|
|
230
|
+
[query.hecks_name, nil]],
|
|
231
|
+
status: error ? 422 : 200)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def query_json(domain, aggregate, query, asked)
|
|
235
|
+
return json(200, query.to_h) if asked.empty?
|
|
236
|
+
|
|
237
|
+
fields = query.attributes.map { |a| FieldShape.resolve(a, aggregate: aggregate) }
|
|
238
|
+
results, error = run_query(domain, aggregate, query, fields, asked)
|
|
239
|
+
return json(422, { error: error.class.name.split("::").last, message: error.message }) if error
|
|
240
|
+
|
|
241
|
+
# id LAST — same reasoning as the other JSON-serializing call
|
|
242
|
+
# sites in this file (see aggregate_route's own comment).
|
|
243
|
+
json(200, results.map { |i| i.state.merge(id: i.id) })
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# `Dispatcher#query` answers an array of plain hashes
|
|
247
|
+
# (`{id:}.merge(state)`, flattened by `QueryInterpreter#call` on
|
|
248
|
+
# every path it can take — native pushdown or the in-memory
|
|
249
|
+
# fallback alike), not `Runtime::Instance`. Wrapped into `Record`
|
|
250
|
+
# here, once, so every renderer downstream reads one shape whether
|
|
251
|
+
# its records came from a query or straight off a repository.
|
|
252
|
+
def run_query(domain, aggregate, query, fields, asked)
|
|
253
|
+
args = Params.extract(fields, asked)
|
|
254
|
+
rows = @dispatcher.query("#{domain}::#{aggregate.hecks_name}.#{query.hecks_name}", **args)
|
|
255
|
+
[rows.map { |row| Record.new(row[:id], row.reject { |k, _| k == :id }) }, nil]
|
|
256
|
+
# L10 (docs/audits/2026-08-10-main-bug-audit.md) — `Params.extract`
|
|
257
|
+
# (params.rb's `extract_list`) reads a list-of-value-object line as
|
|
258
|
+
# JSON (the honest fallback for a multi-attribute list element this
|
|
259
|
+
# prototype's textarea doesn't build a second widget for). A caller
|
|
260
|
+
# who types a non-JSON line into that field raises `JSON::ParserError`
|
|
261
|
+
# BEFORE dispatch ever sees it — both command submission paths
|
|
262
|
+
# already rescue it (`submit_command`, `command_json`); this one
|
|
263
|
+
# didn't, so a malformed list-of-VO query 500'd instead of showing
|
|
264
|
+
# the same 422 every other bad-input path shows.
|
|
265
|
+
rescue *Runtime::DOMAIN_REFUSALS, ArgumentError, TypeError, JSON::ParserError => e
|
|
266
|
+
[nil, e]
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def record_route(request, domain, aggregate, id, format, instance: nil)
|
|
270
|
+
return respond(405, "text/plain", "GET only") unless request.get?
|
|
271
|
+
|
|
272
|
+
instance ||= @registry.repository(domain, aggregate).find(id)
|
|
273
|
+
return not_found(aggregate, id, format) unless instance
|
|
274
|
+
# id LAST — same reasoning as the other JSON-serializing call
|
|
275
|
+
# sites in this file (see aggregate_route's own comment).
|
|
276
|
+
return json(200, instance.state.merge(id: instance.id)) if format != "html"
|
|
277
|
+
|
|
278
|
+
html("#{domain}::#{aggregate.hecks_name} #{id}", RecordRenderer.show(registry: @registry, domain: domain, aggregate: aggregate, id: id),
|
|
279
|
+
breadcrumbs: [[domain, "/"], [aggregate.hecks_name, "/#{domain}/#{aggregate.hecks_name}.html"], [id, nil]])
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def not_found(aggregate, id, format)
|
|
283
|
+
return json(404, { error: "NotFound", message: "no #{aggregate.hecks_name} #{id}" }) if format != "html"
|
|
284
|
+
|
|
285
|
+
respond(404, "text/html; charset=utf-8",
|
|
286
|
+
Page.render(title: "not found", body: "<h1>Not found</h1><p>No #{Escape.html(aggregate.hecks_name)} #{Escape.html(id)}.</p>"))
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def find_aggregate(chapter, name)
|
|
290
|
+
chapter.aggregate(name) || raise(RouteNotFound, "#{chapter.name} has no aggregate #{name.inspect}")
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def command_envelope(command, args)
|
|
294
|
+
Facade::CommandRequest.normalize(args, receiver: command_receiver(command), legacy_receiver: :id)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def command_receiver(command) = command.creates? ? nil : :aggregate
|
|
298
|
+
|
|
299
|
+
def submitted_command(request, aggregate, command)
|
|
300
|
+
if request.media_type == "application/json"
|
|
301
|
+
raw = Facade::JsonDoor.parse(request.body.read)
|
|
302
|
+
envelope = Facade::JsonDoor.command_request(raw, receiver: command_receiver(command),
|
|
303
|
+
legacy_receiver: :id)
|
|
304
|
+
return [raw, envelope]
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
raw = receiver_compatibility(request.POST, command)
|
|
308
|
+
fields = CommandFormRenderer.fields_for(aggregate, command)
|
|
309
|
+
args = Params.extract(fields, raw)
|
|
310
|
+
[raw, command_envelope(command, args)]
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Existing integrations may still submit id. It remains an accepted
|
|
314
|
+
# edge spelling, but new forms expose only the receiver name to.
|
|
315
|
+
def receiver_compatibility(raw, command)
|
|
316
|
+
return raw if command.creates?
|
|
317
|
+
return raw unless raw.key?("id") && !raw.key?("to")
|
|
318
|
+
|
|
319
|
+
raw.merge("to" => raw["id"])
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# ---- rendering ----------------------------------------------------
|
|
323
|
+
|
|
324
|
+
def html(title, body, breadcrumbs: [], status: 200)
|
|
325
|
+
respond(status, "text/html; charset=utf-8", Page.render(title: title, body: body, breadcrumbs: breadcrumbs))
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def json(status, payload)
|
|
329
|
+
respond(status, "application/json", JSON.pretty_generate(payload))
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def redirect(location)
|
|
333
|
+
[302, { "location" => location }, []]
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def respond(status, content_type, body)
|
|
337
|
+
[status, { "content-type" => content_type }, [body]]
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "html"
|
|
3
|
+
require_relative "field_shape"
|
|
4
|
+
require_relative "field_renderer"
|
|
5
|
+
require_relative "reference_options"
|
|
6
|
+
require_relative "params"
|
|
7
|
+
|
|
8
|
+
module Hecks
|
|
9
|
+
module Forms
|
|
10
|
+
# Command -> the page body for its HTML form — the working half of
|
|
11
|
+
# what `command_form.bluebook` names (see docs/command-form-and-query-
|
|
12
|
+
# form-bluebook.md). Every command in every loaded, exposed bluebook
|
|
13
|
+
# renders through here — nothing here is per-command code; see that
|
|
14
|
+
# doc for the 1:1 rule this deliberately holds to for now.
|
|
15
|
+
module CommandFormRenderer
|
|
16
|
+
# `registry` is only used to populate a `:reference` field's
|
|
17
|
+
# `<select>` with real records (including the identity picker itself,
|
|
18
|
+
# for a non-creating command — see `identity_field` below). `values`/
|
|
19
|
+
# `error` carry a sticky re-render after a refused submission; leave
|
|
20
|
+
# both nil/`{}` for a fresh form. `prefill` carries values a caller
|
|
21
|
+
# arrived WITH (typically `?to=...` off a record's own detail page) —
|
|
22
|
+
# kept separate from `values` because a prefill is not an error retry
|
|
23
|
+
# and should not be treated as one by a future reader of this code.
|
|
24
|
+
# The SAME field list a POST handler needs to cast raw params against
|
|
25
|
+
# (params.rb's `Params.extract`) — one derivation, so a page never
|
|
26
|
+
# renders an input the submit handler doesn't also expect.
|
|
27
|
+
def self.fields_for(aggregate, command)
|
|
28
|
+
addressing = command.creates? ? [] : [identity_field(aggregate)]
|
|
29
|
+
addressing + command.attributes.map { |a| FieldShape.resolve(a, aggregate: aggregate) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.render(registry:, domain:, aggregate:, command:, action:, values: nil, error: nil, prefill: {})
|
|
33
|
+
all_fields = fields_for(aggregate, command)
|
|
34
|
+
reference_options = ReferenceOptions.collect(registry, domain, all_fields)
|
|
35
|
+
shown_values = values || prefill
|
|
36
|
+
|
|
37
|
+
<<~HTML
|
|
38
|
+
#{header(domain, aggregate, command)}
|
|
39
|
+
#{error_banner(error)}
|
|
40
|
+
<form method="post" action="#{Escape.attr(action)}" novalidate>
|
|
41
|
+
#{all_fields.map { |f| FieldRenderer.render(f, values: shown_values, errors: field_errors(error), reference_options: reference_options) }.join}
|
|
42
|
+
<div class="actions">
|
|
43
|
+
<button type="submit">#{Escape.html(command.hecks_name)}</button>
|
|
44
|
+
<a class="button secondary" href="#{Escape.attr("/#{domain}/#{aggregate.hecks_name}")}">Cancel</a>
|
|
45
|
+
</div>
|
|
46
|
+
</form>
|
|
47
|
+
#{inspect_panel(domain, aggregate, command, action, all_fields)}
|
|
48
|
+
HTML
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.identity_field(aggregate)
|
|
52
|
+
Field.new(path: "to", label: "#{aggregate.hecks_name} (#{aggregate.identity_paths.join(', ')})",
|
|
53
|
+
kind: :reference, html_type: "text", target_aggregate: aggregate,
|
|
54
|
+
help: "The record this command acts on.")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.header(domain, aggregate, command)
|
|
58
|
+
<<~HTML
|
|
59
|
+
<h1>#{Escape.html("#{domain}::#{aggregate.hecks_name}.#{command.hecks_name}")}</h1>
|
|
60
|
+
#{command.role ? %(<span class="badge role" title="Declared on the command; this prototype dispatches with no caller bound, so the check does not run.">role: #{Escape.html(command.role)} (not enforced here)</span>) : ''}
|
|
61
|
+
#{command.creates? ? %(<span class="badge">creates a new #{Escape.html(aggregate.hecks_name)}</span>) : ''}
|
|
62
|
+
#{command.goal ? %(<p class="goal">#{Escape.html(command.goal)}</p>) : ''}
|
|
63
|
+
#{givens_callout(command)}
|
|
64
|
+
HTML
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.givens_callout(command)
|
|
68
|
+
return "" if command.givens.empty?
|
|
69
|
+
|
|
70
|
+
items = command.givens.map { |given| "<li>#{Escape.html(given.description)}</li>" }
|
|
71
|
+
%(<div class="callout"><strong>Preconditions</strong> — refused if any fail:<ul>#{items.join}</ul></div>)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.error_banner(error)
|
|
75
|
+
return "" unless error
|
|
76
|
+
|
|
77
|
+
<<~HTML
|
|
78
|
+
<div class="error-banner" role="alert">
|
|
79
|
+
<p><strong>#{Escape.html(error.class.name.split('::').last)}</strong> — #{Escape.html(error.message)}</p>
|
|
80
|
+
</div>
|
|
81
|
+
HTML
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# No structured field attribution exists on a domain refusal today
|
|
85
|
+
# (it is a typed exception with a rendered message — see
|
|
86
|
+
# docs/command-form-and-query-form-bluebook.md's note on
|
|
87
|
+
# `RefusalWording`), so this returns empty rather than guessing which
|
|
88
|
+
# field a message meant; the banner above carries the real text
|
|
89
|
+
# instead of a misattributed hint.
|
|
90
|
+
def self.field_errors(_error) = {}
|
|
91
|
+
|
|
92
|
+
def self.inspect_panel(domain, aggregate, command, action, fields)
|
|
93
|
+
verb = "#{domain}::#{aggregate.hecks_name}.#{command.hecks_name}"
|
|
94
|
+
paths = Params.paths(fields)
|
|
95
|
+
curl = <<~SH.strip
|
|
96
|
+
curl -X POST '#{action}' \\
|
|
97
|
+
#{paths.map { |path| "-d '#{path}=...'" }.join(" \\\n ")}
|
|
98
|
+
SH
|
|
99
|
+
<<~HTML
|
|
100
|
+
<details class="inspect">
|
|
101
|
+
<summary>Inspect — #{Escape.html(verb)}</summary>
|
|
102
|
+
<p>Emits: #{command.emits.empty? ? '<em>nothing declared</em>' : command.emits.map { |e| "<code>#{Escape.html(e)}</code>" }.join(', ')}</p>
|
|
103
|
+
<p>Equivalent request (as <code>curl</code>) — every field is <code>#{Escape.html('name.path')}</code>-encoded, form or JSON alike:</p>
|
|
104
|
+
<div class="link-row"><code id="curl-snippet">#{Escape.html(curl)}</code><button type="button" class="copy" data-copy="#curl-snippet">copy</button></div>
|
|
105
|
+
<p>Fields this command takes: #{paths.map { |p| "<code>#{Escape.html(p)}</code>" }.join(', ')}</p>
|
|
106
|
+
<p>The command's own declaration, as the runtime holds it:</p>
|
|
107
|
+
<pre>#{Escape.html(JSON.pretty_generate(command.to_h))}</pre>
|
|
108
|
+
</details>
|
|
109
|
+
HTML
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
require_relative "html"
|
|
2
|
+
require_relative "field_shape"
|
|
3
|
+
require_relative "../naming"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Forms
|
|
7
|
+
# A Field (field_shape.rb) -> the `<div class="field">...</div>` or
|
|
8
|
+
# `<fieldset>...</fieldset>` markup for it. One renderer, called
|
|
9
|
+
# recursively for a group's children, shared by CommandFormRenderer
|
|
10
|
+
# (POST) and QueryFormRenderer (GET) — the same attribute shape asks
|
|
11
|
+
# for the same input either way; only the surrounding `<form>`'s
|
|
12
|
+
# method differs.
|
|
13
|
+
module FieldRenderer
|
|
14
|
+
# `values` is the nested hash a sticky re-render (a rejected command,
|
|
15
|
+
# a submitted query) carries back — read with the SAME dotted path a
|
|
16
|
+
# Field already carries, via `dig`. `reference_options` maps a
|
|
17
|
+
# `:reference` field's own path to `[[id, label], ...]`, built by the
|
|
18
|
+
# caller (it needs a repository; this module stays pure markup).
|
|
19
|
+
def self.render(field, values: {}, errors: nil, reference_options: {})
|
|
20
|
+
case field.kind
|
|
21
|
+
when :group then group(field, values, errors, reference_options, tag: "fieldset")
|
|
22
|
+
when :money then group(field, values, errors, reference_options, tag: "fieldset", css: "money")
|
|
23
|
+
when :list then list(field, values, errors)
|
|
24
|
+
else leaf(field, values, errors, reference_options)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.group(field, values, errors, reference_options, tag:, css: nil)
|
|
29
|
+
inner = field.children.map { |child| render(child, values: values, errors: errors, reference_options: reference_options) }
|
|
30
|
+
<<~HTML
|
|
31
|
+
<#{tag}#{css ? %( class="#{css}") : ""}>
|
|
32
|
+
<legend>#{Escape.html(field.label)}#{required_mark(field)}</legend>
|
|
33
|
+
#{inner.join("\n")}
|
|
34
|
+
</#{tag}>
|
|
35
|
+
HTML
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.list(field, values, _errors)
|
|
39
|
+
item = field.children.first
|
|
40
|
+
current = Array(dig(values, field.path)).join("\n")
|
|
41
|
+
<<~HTML
|
|
42
|
+
<div class="field">
|
|
43
|
+
<label for="#{dom_id(field.path)}">#{Escape.html(field.label)}#{required_mark(field)}</label>
|
|
44
|
+
<textarea id="#{dom_id(field.path)}" name="#{Escape.attr(field.path)}"
|
|
45
|
+
#{aria(field)} placeholder="one #{Escape.attr(item.label.downcase)} per line">#{Escape.html(current)}</textarea>
|
|
46
|
+
<span class="help" id="#{dom_id(field.path)}-help">One #{Escape.html(item.label.downcase)} per line#{item.leaf? ? '' : ' — each line is one JSON object'}.</span>
|
|
47
|
+
</div>
|
|
48
|
+
HTML
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.leaf(field, values, errors, reference_options)
|
|
52
|
+
value = dig(values, field.path)
|
|
53
|
+
error = errors && errors[field.path]
|
|
54
|
+
body = case field.kind
|
|
55
|
+
when :boolean then checkbox(field, value)
|
|
56
|
+
when :radio then radio_group(field, value)
|
|
57
|
+
when :select then select(field, value)
|
|
58
|
+
when :textarea then textarea(field, value)
|
|
59
|
+
when :reference then reference_select(field, value, reference_options[field.path])
|
|
60
|
+
else input(field, value)
|
|
61
|
+
end
|
|
62
|
+
wrap(field, body, error)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.wrap(field, body, error)
|
|
66
|
+
<<~HTML
|
|
67
|
+
<div class="field#{error ? ' has-error' : ''}">
|
|
68
|
+
#{field.kind == :boolean ? '' : %(<label for="#{dom_id(field.path)}">#{Escape.html(field.label)}#{required_mark(field)}</label>)}
|
|
69
|
+
#{body}
|
|
70
|
+
#{field.help ? %(<span class="help" id="#{dom_id(field.path)}-help">#{Escape.html(field.help)}</span>) : ''}
|
|
71
|
+
#{error ? %(<span class="help" role="alert">#{Escape.html(error)}</span>) : ''}
|
|
72
|
+
</div>
|
|
73
|
+
HTML
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.input(field, value)
|
|
77
|
+
money = field.kind == :number && field.help.to_s.include?("cents")
|
|
78
|
+
tag = Tag.void("input", id: dom_id(field.path), name: field.path, type: field.html_type,
|
|
79
|
+
value: value || field.default, step: field.step, pattern: leaf_pattern(field),
|
|
80
|
+
placeholder: field.default, **aria_attrs(field),
|
|
81
|
+
**(money ? { data_money_cents: money_preview_target(field) } : {}))
|
|
82
|
+
return tag unless money
|
|
83
|
+
|
|
84
|
+
%(#{tag} <span id="#{dom_id(field.path)}-preview" class="mono help" aria-live="polite"></span>)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.textarea(field, value)
|
|
88
|
+
%(<textarea id="#{dom_id(field.path)}" name="#{Escape.attr(field.path)}" #{aria(field)}>#{Escape.html(value)}</textarea>)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.checkbox(field, value)
|
|
92
|
+
checked = value.nil? ? field.default == true : [true, "true", "on", "1"].include?(value)
|
|
93
|
+
<<~HTML
|
|
94
|
+
<div class="checkbox-row">
|
|
95
|
+
<input type="hidden" name="#{Escape.attr(field.path)}" value="0">
|
|
96
|
+
#{Tag.void('input', id: dom_id(field.path), name: field.path, type: 'checkbox', value: '1', checked: checked)}
|
|
97
|
+
<label for="#{dom_id(field.path)}">#{Escape.html(field.label)}</label>
|
|
98
|
+
</div>
|
|
99
|
+
HTML
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.radio_group(field, value)
|
|
103
|
+
selected = value || field.default
|
|
104
|
+
options = field.options.map do |option_value, option_label|
|
|
105
|
+
checked = option_value.to_s == selected.to_s
|
|
106
|
+
id = "#{dom_id(field.path)}-#{Naming.snake(option_value)}"
|
|
107
|
+
<<~HTML
|
|
108
|
+
<label>#{Tag.void('input', id: id, type: 'radio', name: field.path, value: option_value, checked: checked)} #{Escape.html(option_label)}</label>
|
|
109
|
+
HTML
|
|
110
|
+
end
|
|
111
|
+
%(<div class="radio-group" role="radiogroup">#{options.join}</div>)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.select(field, value)
|
|
115
|
+
selected = value || field.default
|
|
116
|
+
options = field.options.map do |option_value, option_label|
|
|
117
|
+
%(<option value="#{Escape.attr(option_value)}"#{option_value.to_s == selected.to_s ? ' selected' : ''}>#{Escape.html(option_label)}</option>)
|
|
118
|
+
end
|
|
119
|
+
blank = field.optional? ? %(<option value="">—</option>) : ""
|
|
120
|
+
%(<select id="#{dom_id(field.path)}" name="#{Escape.attr(field.path)}" #{aria(field)}>#{blank}#{options.join}</select>)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.reference_select(field, value, options)
|
|
124
|
+
return input(field.tap { |f| f.html_type = "text" }, value) unless options && !options.empty?
|
|
125
|
+
|
|
126
|
+
rendered = options.map do |id, label|
|
|
127
|
+
%(<option value="#{Escape.attr(id)}"#{id.to_s == value.to_s ? ' selected' : ''}>#{Escape.html(label)}</option>)
|
|
128
|
+
end
|
|
129
|
+
blank = field.optional? ? %(<option value="">—</option>) : %(<option value="" disabled#{value ? '' : ' selected'}>choose one…</option>)
|
|
130
|
+
%(<select id="#{dom_id(field.path)}" name="#{Escape.attr(field.path)}" #{aria(field)}>#{blank}#{rendered.join}</select>)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.required_mark(field) = field.required? ? %(<span class="required-mark" title="required">*</span>) : ""
|
|
134
|
+
def self.dom_id(path) = "f-#{path.to_s.tr('.', '-')}"
|
|
135
|
+
def self.leaf_pattern(field) = field.kind == :reference ? nil : field.pattern
|
|
136
|
+
def self.money_preview_target(field) = "##{dom_id(field.path)}-preview"
|
|
137
|
+
|
|
138
|
+
def self.aria(field)
|
|
139
|
+
Tag.attrs(required: field.required?, aria_describedby: field.help ? "#{dom_id(field.path)}-help" : nil)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.aria_attrs(field)
|
|
143
|
+
{ required: field.required?, aria_describedby: field.help ? "#{dom_id(field.path)}-help" : nil }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# `values` is a nested hash; `path` a dotted string using the SAME
|
|
147
|
+
# segment spelling the hash keys are built from (`leaf_key` in
|
|
148
|
+
# params.rb) — symbols one level down from a group, strings at the
|
|
149
|
+
# flat top when a sticky POST re-render hands raw params back untouched.
|
|
150
|
+
def self.dig(values, path)
|
|
151
|
+
return nil unless values.is_a?(Hash)
|
|
152
|
+
|
|
153
|
+
# A sticky re-render after a refused submission hands back the RAW
|
|
154
|
+
# flat params (`{"amount.cents"=>"1050"}` — the same shape the form
|
|
155
|
+
# posted, string values and all, dotted key intact) ; a prefill from
|
|
156
|
+
# an existing record's own state hands back a NESTED hash instead
|
|
157
|
+
# (`{amount: {cents: 1050}}`). Flat wins when both would answer,
|
|
158
|
+
# since only the raw form is ever what the caller actually typed.
|
|
159
|
+
# `key?` decides which spelling answers, at every step below —
|
|
160
|
+
# never `||`, which would treat a genuinely-held `false` the
|
|
161
|
+
# same as an absent key and fall through to `nil`.
|
|
162
|
+
str = path.to_s
|
|
163
|
+
return values[str] if values.key?(str)
|
|
164
|
+
|
|
165
|
+
sym = path.to_sym
|
|
166
|
+
return values[sym] if values.key?(sym)
|
|
167
|
+
|
|
168
|
+
str.split(".").reduce(values) do |acc, segment|
|
|
169
|
+
break nil unless acc.is_a?(Hash)
|
|
170
|
+
|
|
171
|
+
seg_sym = segment.to_sym
|
|
172
|
+
acc.key?(seg_sym) ? acc[seg_sym] : acc[segment]
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|