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,1865 @@
|
|
|
1
|
+
//! Mints Postgres eras, porting `lib/hecks/adapters/driven/postgres_era/
|
|
2
|
+
//! lineage/*.rb`'s SQL verbatim; this crate has no bluebook parser (ADR 0012).
|
|
3
|
+
|
|
4
|
+
use anyhow::Context;
|
|
5
|
+
use crate::journal::quote_ident;
|
|
6
|
+
use crate::reference_transform;
|
|
7
|
+
use crate::reference_validate;
|
|
8
|
+
use crate::storage_shape;
|
|
9
|
+
use serde_json::Value;
|
|
10
|
+
use sha2::{Digest, Sha256};
|
|
11
|
+
use tokio_postgres::GenericClient;
|
|
12
|
+
|
|
13
|
+
fn journal_table(domain: &str) -> String {
|
|
14
|
+
format!("hecks_journal_{}", storage_shape_snake(domain))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
fn sequence(domain: &str) -> String {
|
|
18
|
+
format!("{}_ordinal", journal_table(domain))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fn partition(domain: &str, era: i32) -> String {
|
|
22
|
+
format!("{}_era_{era}", journal_table(domain))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Domain-qualified: two domains whose aggregate names snake_case to the
|
|
26
|
+
// same storage_name would otherwise collide on the same relations (docs/decisions/0059).
|
|
27
|
+
fn head_snapshot(domain: &str, storage_name: &str, era: i32) -> String {
|
|
28
|
+
crate::journal::qualified_name(domain, &format!("{storage_name}_head_snapshot_{era}"))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
fn matview(domain: &str, storage_name: &str, era: i32, label: &str) -> String {
|
|
32
|
+
crate::journal::qualified_name(domain, &format!("{storage_name}_lineage_{era}_{label}"))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fn head_view(domain: &str, storage_name: &str) -> String {
|
|
36
|
+
crate::journal::qualified_name(domain, &format!("{storage_name}_head"))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// `Naming.snake` — same pure syntactic transform `crate::journal::snake`
|
|
40
|
+
// already ports; reused directly rather than duplicated a second time.
|
|
41
|
+
fn storage_shape_snake(name: &str) -> String {
|
|
42
|
+
crate::journal::snake(name)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fn text_literal(text: &str) -> String {
|
|
46
|
+
format!("'{}'", text.replace('\'', "''"))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// One aggregate's identity: its domain name and its storage (snake_case)
|
|
50
|
+
/// name, the pair `ir.json`'s `lineage.capable_aggregates` carries.
|
|
51
|
+
#[derive(Debug, Clone)]
|
|
52
|
+
pub struct Aggregate {
|
|
53
|
+
pub name: String,
|
|
54
|
+
pub storage_name: String,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// One aggregate's compiled translation rule within an edge.
|
|
58
|
+
#[derive(Debug, Clone)]
|
|
59
|
+
pub struct EdgeAggregate {
|
|
60
|
+
pub name: String,
|
|
61
|
+
pub was: Option<String>,
|
|
62
|
+
pub has_compute_or_rekey: bool,
|
|
63
|
+
pub compiled_state_expression: String,
|
|
64
|
+
pub compiled_id_expression: Option<String>,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// One translation edge between two era shapes.
|
|
68
|
+
#[derive(Debug, Clone)]
|
|
69
|
+
pub struct Edge {
|
|
70
|
+
pub from: String,
|
|
71
|
+
pub to: String,
|
|
72
|
+
pub aggregates: Vec<EdgeAggregate>,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
impl Edge {
|
|
76
|
+
fn for_aggregate(&self, name: &str) -> Option<&EdgeAggregate> {
|
|
77
|
+
self.aggregates.iter().find(|a| a.name == name)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// `ir.json`'s `translations` array, filtered to one domain and read back
|
|
82
|
+
/// generically — this crate links no codegen crate (ADR 0012).
|
|
83
|
+
pub fn parse_edges(ir: &Value, domain: &str) -> Vec<Edge> {
|
|
84
|
+
ir.get("translations")
|
|
85
|
+
.and_then(Value::as_array)
|
|
86
|
+
.map(|list| {
|
|
87
|
+
list.iter()
|
|
88
|
+
.filter(|edge| edge.get("domain").and_then(Value::as_str) == Some(domain))
|
|
89
|
+
.map(|edge| Edge {
|
|
90
|
+
from: edge.get("from").and_then(Value::as_str).unwrap_or_default().to_string(),
|
|
91
|
+
to: edge.get("to").and_then(Value::as_str).unwrap_or_default().to_string(),
|
|
92
|
+
aggregates: edge
|
|
93
|
+
.get("aggregates")
|
|
94
|
+
.and_then(Value::as_array)
|
|
95
|
+
.map(|aggs| {
|
|
96
|
+
aggs.iter()
|
|
97
|
+
.map(|agg| EdgeAggregate {
|
|
98
|
+
name: agg.get("name").and_then(Value::as_str).unwrap_or_default().to_string(),
|
|
99
|
+
was: agg.get("was").and_then(Value::as_str).map(str::to_string),
|
|
100
|
+
has_compute_or_rekey: !agg.get("computes").and_then(Value::as_array).map(Vec::is_empty).unwrap_or(true)
|
|
101
|
+
|| !agg.get("rekeys").and_then(Value::as_array).map(Vec::is_empty).unwrap_or(true),
|
|
102
|
+
compiled_state_expression: agg
|
|
103
|
+
.get("compiled_state_expression")
|
|
104
|
+
.and_then(Value::as_str)
|
|
105
|
+
.unwrap_or("state")
|
|
106
|
+
.to_string(),
|
|
107
|
+
compiled_id_expression: agg.get("compiled_id_expression").and_then(Value::as_str).map(str::to_string),
|
|
108
|
+
})
|
|
109
|
+
.collect()
|
|
110
|
+
})
|
|
111
|
+
.unwrap_or_default(),
|
|
112
|
+
})
|
|
113
|
+
.collect()
|
|
114
|
+
})
|
|
115
|
+
.unwrap_or_default()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// The edge chain from era 1's label to `to_label`, in mint order. Refuses
|
|
119
|
+
/// when some era's label has no edge leaving it toward the next.
|
|
120
|
+
pub fn edge_chain<'a>(edges: &'a [Edge], labels: &[String]) -> anyhow::Result<Vec<&'a Edge>> {
|
|
121
|
+
(0..labels.len() - 1)
|
|
122
|
+
.map(|index| {
|
|
123
|
+
edges
|
|
124
|
+
.iter()
|
|
125
|
+
.find(|edge| edge.from == labels[index] && edge.to == labels[index + 1])
|
|
126
|
+
.ok_or_else(|| {
|
|
127
|
+
anyhow::anyhow!(
|
|
128
|
+
"the edge chain is broken at era {} — no translation leads {} to {}; restore bluebook/translations/",
|
|
129
|
+
index + 1,
|
|
130
|
+
labels[index],
|
|
131
|
+
labels[index + 1]
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
.collect()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// What `main()`'s boot gate should do, given the held eras and this
|
|
139
|
+
/// binary's shape label. Pure, so the four-way branch is testable without Postgres.
|
|
140
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
141
|
+
pub enum BootDecision {
|
|
142
|
+
/// A held era already names this exact shape — boot at it. RLS
|
|
143
|
+
/// refuses a write if it's superseded before the transaction lands.
|
|
144
|
+
UseExisting { ordinal: i32 },
|
|
145
|
+
/// No held era at all — this domain is new to this database. Mint era 1.
|
|
146
|
+
HoldFirst,
|
|
147
|
+
/// The latest held era differs from this binary's shape — walk the
|
|
148
|
+
/// edge chain from it and mint the next ordinal.
|
|
149
|
+
Mint { ordinal: i32, from_ordinal: i32, from_label: String },
|
|
150
|
+
/// The latest held era has no label yet — this crate has no parser to
|
|
151
|
+
/// name it, so it refuses rather than guess whether it matches.
|
|
152
|
+
LatestUnnamed { ordinal: i32 },
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
impl BootDecision {
|
|
156
|
+
/// The outcome's name, as the boot log reports it.
|
|
157
|
+
pub fn name(&self) -> &'static str {
|
|
158
|
+
match self {
|
|
159
|
+
BootDecision::UseExisting { .. } => "use_existing",
|
|
160
|
+
BootDecision::HoldFirst => "hold_first",
|
|
161
|
+
BootDecision::Mint { .. } => "mint",
|
|
162
|
+
BootDecision::LatestUnnamed { .. } => "latest_unnamed",
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// `held` must already be in ordinal order (as `journal::held_eras` returns it).
|
|
168
|
+
pub fn decide_boot_action(held: &[crate::journal::HeldEra], my_label: &str) -> BootDecision {
|
|
169
|
+
if let Some(matching) = held.iter().find(|held_era| held_era.label.as_deref() == Some(my_label)) {
|
|
170
|
+
return BootDecision::UseExisting { ordinal: matching.ordinal };
|
|
171
|
+
}
|
|
172
|
+
if held.is_empty() {
|
|
173
|
+
return BootDecision::HoldFirst;
|
|
174
|
+
}
|
|
175
|
+
let latest = held.last().expect("checked non-empty above");
|
|
176
|
+
match &latest.label {
|
|
177
|
+
None => BootDecision::LatestUnnamed { ordinal: latest.ordinal },
|
|
178
|
+
Some(label) => BootDecision::Mint {
|
|
179
|
+
ordinal: latest.ordinal + 1,
|
|
180
|
+
from_ordinal: latest.ordinal,
|
|
181
|
+
from_label: label.clone(),
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async fn provisioner<C: GenericClient>(client: &C, journal: &str) -> anyhow::Result<bool> {
|
|
187
|
+
let row = client
|
|
188
|
+
.query_opt(
|
|
189
|
+
"SELECT pg_get_userbyid(relowner) = current_user AS owned FROM pg_class WHERE relname = $1 AND pg_table_is_visible(oid)",
|
|
190
|
+
&[&journal],
|
|
191
|
+
)
|
|
192
|
+
.await?;
|
|
193
|
+
Ok(match row {
|
|
194
|
+
None => true,
|
|
195
|
+
Some(row) => row.get::<_, bool>("owned"),
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async fn partition_attached<C: GenericClient>(client: &C, partition_name: &str, journal: &str) -> anyhow::Result<bool> {
|
|
200
|
+
let row = client
|
|
201
|
+
.query_opt(
|
|
202
|
+
"SELECT 1 FROM pg_inherits i \
|
|
203
|
+
JOIN pg_class child ON child.oid = i.inhrelid \
|
|
204
|
+
JOIN pg_class parent ON parent.oid = i.inhparent \
|
|
205
|
+
WHERE child.relname = $1 AND parent.relname = $2 \
|
|
206
|
+
AND pg_table_is_visible(child.oid) AND pg_table_is_visible(parent.oid)",
|
|
207
|
+
&[&partition_name, &journal],
|
|
208
|
+
)
|
|
209
|
+
.await?;
|
|
210
|
+
Ok(row.is_some())
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async fn ensure_partition<C: GenericClient>(client: &C, domain: &str, era: i32) -> anyhow::Result<()> {
|
|
214
|
+
let journal = journal_table(domain);
|
|
215
|
+
let part = partition(domain, era);
|
|
216
|
+
if partition_attached(client, &part, &journal).await? {
|
|
217
|
+
return Ok(());
|
|
218
|
+
}
|
|
219
|
+
client
|
|
220
|
+
.batch_execute(&format!("CREATE TABLE IF NOT EXISTS {} (LIKE {} INCLUDING DEFAULTS)", quote_ident(&part), quote_ident(&journal)))
|
|
221
|
+
.await?;
|
|
222
|
+
client
|
|
223
|
+
.batch_execute(&format!("ALTER TABLE {} ATTACH PARTITION {} FOR VALUES IN ({era})", quote_ident(&journal), quote_ident(&part)))
|
|
224
|
+
.await?;
|
|
225
|
+
Ok(())
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async fn install_transforms<C: GenericClient>(client: &C) -> anyhow::Result<()> {
|
|
229
|
+
const FUNCTIONS: &[&str] = &[
|
|
230
|
+
r#"CREATE OR REPLACE FUNCTION hecks_tr_extract(state jsonb, path text[], OUT remaining jsonb, OUT value jsonb, OUT present boolean)
|
|
231
|
+
LANGUAGE plpgsql IMMUTABLE AS $fn$
|
|
232
|
+
DECLARE parent jsonb; leaf text;
|
|
233
|
+
BEGIN
|
|
234
|
+
remaining := state;
|
|
235
|
+
present := false;
|
|
236
|
+
leaf := path[array_upper(path, 1)];
|
|
237
|
+
IF array_length(path, 1) = 1 THEN
|
|
238
|
+
IF state ? leaf THEN
|
|
239
|
+
present := true;
|
|
240
|
+
value := state -> leaf;
|
|
241
|
+
remaining := state - leaf;
|
|
242
|
+
END IF;
|
|
243
|
+
RETURN;
|
|
244
|
+
END IF;
|
|
245
|
+
parent := state #> path[1:array_upper(path, 1) - 1];
|
|
246
|
+
IF jsonb_typeof(parent) = 'object' AND parent ? leaf THEN
|
|
247
|
+
present := true;
|
|
248
|
+
value := parent -> leaf;
|
|
249
|
+
parent := parent - leaf;
|
|
250
|
+
IF parent = '{}'::jsonb THEN
|
|
251
|
+
remaining := state - path[1];
|
|
252
|
+
ELSE
|
|
253
|
+
remaining := jsonb_set(state, path[1:array_upper(path, 1) - 1], parent);
|
|
254
|
+
END IF;
|
|
255
|
+
END IF;
|
|
256
|
+
END $fn$"#,
|
|
257
|
+
r#"CREATE OR REPLACE FUNCTION hecks_tr_insert(state jsonb, path text[], value jsonb, rule_label text) RETURNS jsonb
|
|
258
|
+
LANGUAGE plpgsql IMMUTABLE AS $fn$
|
|
259
|
+
BEGIN
|
|
260
|
+
IF array_length(path, 1) = 1 THEN
|
|
261
|
+
RETURN state || jsonb_build_object(path[1], value);
|
|
262
|
+
END IF;
|
|
263
|
+
IF state ? path[1] AND jsonb_typeof(state -> path[1]) <> 'object' THEN
|
|
264
|
+
RAISE EXCEPTION 'cannot %: % already holds %, not a value this can nest under — moving into it would discard that value silently. Rename or drop % first.',
|
|
265
|
+
rule_label, path[1], state -> path[1], path[1];
|
|
266
|
+
END IF;
|
|
267
|
+
IF state -> path[1] IS NULL THEN
|
|
268
|
+
state := state || jsonb_build_object(path[1], '{}'::jsonb);
|
|
269
|
+
END IF;
|
|
270
|
+
RETURN jsonb_set(state, path, value);
|
|
271
|
+
END $fn$"#,
|
|
272
|
+
r#"CREATE OR REPLACE FUNCTION hecks_tr_rename(state jsonb, old_name text, new_name text) RETURNS jsonb
|
|
273
|
+
LANGUAGE sql IMMUTABLE AS $fn$
|
|
274
|
+
SELECT CASE WHEN state ? old_name
|
|
275
|
+
THEN (state - old_name) || jsonb_build_object(new_name, state -> old_name)
|
|
276
|
+
ELSE state END
|
|
277
|
+
$fn$"#,
|
|
278
|
+
r#"CREATE OR REPLACE FUNCTION hecks_tr_move(state jsonb, from_path text[], to_path text[], rule_label text) RETURNS jsonb
|
|
279
|
+
LANGUAGE plpgsql IMMUTABLE AS $fn$
|
|
280
|
+
DECLARE extracted record;
|
|
281
|
+
BEGIN
|
|
282
|
+
SELECT * INTO extracted FROM hecks_tr_extract(state, from_path);
|
|
283
|
+
IF NOT extracted.present THEN RETURN state; END IF;
|
|
284
|
+
RETURN hecks_tr_insert(extracted.remaining, to_path, extracted.value, rule_label);
|
|
285
|
+
END $fn$"#,
|
|
286
|
+
r#"CREATE OR REPLACE FUNCTION hecks_tr_convert(state jsonb, from_path text[], to_path text[], pairs jsonb, from_label text, rule_label text) RETURNS jsonb
|
|
287
|
+
LANGUAGE plpgsql IMMUTABLE AS $fn$
|
|
288
|
+
DECLARE extracted record; pair jsonb;
|
|
289
|
+
BEGIN
|
|
290
|
+
SELECT * INTO extracted FROM hecks_tr_extract(state, from_path);
|
|
291
|
+
IF NOT extracted.present THEN RETURN state; END IF;
|
|
292
|
+
FOR pair IN SELECT * FROM jsonb_array_elements(pairs) LOOP
|
|
293
|
+
IF pair -> 0 = extracted.value THEN
|
|
294
|
+
RETURN hecks_tr_insert(extracted.remaining, to_path, pair -> 1, rule_label);
|
|
295
|
+
END IF;
|
|
296
|
+
END LOOP;
|
|
297
|
+
RAISE EXCEPTION 'cannot translate %: % has no mapping in its convert''s values: table. Add % => ... to cover it.',
|
|
298
|
+
from_label, extracted.value, extracted.value;
|
|
299
|
+
END $fn$"#,
|
|
300
|
+
r#"CREATE OR REPLACE FUNCTION hecks_tr_drop(state jsonb, path text[]) RETURNS jsonb
|
|
301
|
+
LANGUAGE plpgsql IMMUTABLE AS $fn$
|
|
302
|
+
DECLARE extracted record;
|
|
303
|
+
BEGIN
|
|
304
|
+
SELECT * INTO extracted FROM hecks_tr_extract(state, path);
|
|
305
|
+
RETURN extracted.remaining;
|
|
306
|
+
END $fn$"#,
|
|
307
|
+
];
|
|
308
|
+
for sql in FUNCTIONS {
|
|
309
|
+
client.batch_execute(sql).await?;
|
|
310
|
+
}
|
|
311
|
+
Ok(())
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/// Provisions a domain's tables and functions once, ever. Idempotent, and
|
|
315
|
+
/// gated by ownership since a non-owner role has no business running DDL.
|
|
316
|
+
pub async fn ensure_base<C: GenericClient>(client: &C, domain: &str) -> anyhow::Result<()> {
|
|
317
|
+
let journal = journal_table(domain);
|
|
318
|
+
if !provisioner(client, &journal).await? {
|
|
319
|
+
return Ok(());
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
client
|
|
323
|
+
.batch_execute(
|
|
324
|
+
"CREATE TABLE IF NOT EXISTS hecks_eras (\
|
|
325
|
+
domain text NOT NULL, ordinal int NOT NULL, hash text, label text, \
|
|
326
|
+
held_text text NOT NULL, watermark bigint, \
|
|
327
|
+
PRIMARY KEY (domain, ordinal))",
|
|
328
|
+
)
|
|
329
|
+
.await?;
|
|
330
|
+
client.batch_execute("ALTER TABLE hecks_eras ADD COLUMN IF NOT EXISTS held_digest text").await?;
|
|
331
|
+
client.batch_execute("ALTER TABLE hecks_eras ADD COLUMN IF NOT EXISTS held_projection jsonb").await?;
|
|
332
|
+
client.batch_execute("ALTER TABLE hecks_eras ADD COLUMN IF NOT EXISTS canon_form int").await?;
|
|
333
|
+
client
|
|
334
|
+
.batch_execute(
|
|
335
|
+
"CREATE TABLE IF NOT EXISTS hecks_era_texts (\
|
|
336
|
+
domain text NOT NULL, ordinal int NOT NULL, digest text NOT NULL, held_text text NOT NULL, \
|
|
337
|
+
archived_at timestamptz NOT NULL DEFAULT now(), \
|
|
338
|
+
PRIMARY KEY (domain, ordinal, digest))",
|
|
339
|
+
)
|
|
340
|
+
.await?;
|
|
341
|
+
client
|
|
342
|
+
.batch_execute(
|
|
343
|
+
"CREATE TABLE IF NOT EXISTS hecks_approvals (\
|
|
344
|
+
domain text NOT NULL, from_label text NOT NULL, to_label text NOT NULL, \
|
|
345
|
+
edge_digest text NOT NULL, reviewed_ordinal bigint NOT NULL, \
|
|
346
|
+
approved_at timestamptz NOT NULL DEFAULT now())",
|
|
347
|
+
)
|
|
348
|
+
.await?;
|
|
349
|
+
client.batch_execute("CREATE TABLE IF NOT EXISTS hecks_backfill_progress (\
|
|
350
|
+
target text PRIMARY KEY, cursor text, completed boolean NOT NULL DEFAULT false, \
|
|
351
|
+
updated_at timestamptz NOT NULL DEFAULT now())").await?;
|
|
352
|
+
|
|
353
|
+
let seq = sequence(domain);
|
|
354
|
+
client.batch_execute(&format!("CREATE SEQUENCE IF NOT EXISTS {}", quote_ident(&seq))).await?;
|
|
355
|
+
client
|
|
356
|
+
.batch_execute(&format!(
|
|
357
|
+
"CREATE TABLE IF NOT EXISTS {} (\
|
|
358
|
+
ordinal bigint NOT NULL DEFAULT nextval('{seq}'), era int NOT NULL, \
|
|
359
|
+
aggregate text NOT NULL, aggregate_id text NOT NULL, \
|
|
360
|
+
operation text NOT NULL DEFAULT 'save', state jsonb, mirrors jsonb) \
|
|
361
|
+
PARTITION BY LIST (era)",
|
|
362
|
+
quote_ident(&journal)
|
|
363
|
+
))
|
|
364
|
+
.await?;
|
|
365
|
+
|
|
366
|
+
ensure_partition(client, domain, 1).await?;
|
|
367
|
+
|
|
368
|
+
// Revokes only once: `relacl IS NULL` is the one honest signal, since
|
|
369
|
+
// `has_table_privilege` reads false for both "never revoked" and "already revoked".
|
|
370
|
+
let relacl_null = client
|
|
371
|
+
.query_opt(
|
|
372
|
+
"SELECT relacl IS NULL FROM pg_class WHERE relname = $1 AND pg_table_is_visible(oid)",
|
|
373
|
+
&[&journal],
|
|
374
|
+
)
|
|
375
|
+
.await?
|
|
376
|
+
.map(|row| row.get::<_, bool>(0))
|
|
377
|
+
.unwrap_or(true);
|
|
378
|
+
if relacl_null {
|
|
379
|
+
client.batch_execute(&format!("REVOKE UPDATE, DELETE ON {} FROM PUBLIC", quote_ident(&journal))).await?;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
let current = client
|
|
383
|
+
.query_opt(
|
|
384
|
+
"SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = $1 AND pg_table_is_visible(oid)",
|
|
385
|
+
&[&journal],
|
|
386
|
+
)
|
|
387
|
+
.await?;
|
|
388
|
+
let (rowsecurity, forcerowsecurity) = current.map(|row| (row.get::<_, bool>("relrowsecurity"), row.get::<_, bool>("relforcerowsecurity"))).unwrap_or((false, false));
|
|
389
|
+
if !rowsecurity {
|
|
390
|
+
client.batch_execute(&format!("ALTER TABLE {} ENABLE ROW LEVEL SECURITY", quote_ident(&journal))).await?;
|
|
391
|
+
}
|
|
392
|
+
if !forcerowsecurity {
|
|
393
|
+
client.batch_execute(&format!("ALTER TABLE {} FORCE ROW LEVEL SECURITY", quote_ident(&journal))).await?;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
install_transforms(client).await?;
|
|
397
|
+
Ok(())
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const CHUNK_SIZE: i64 = 5_000;
|
|
401
|
+
|
|
402
|
+
async fn backfill_progress<C: GenericClient>(client: &C, target: &str) -> anyhow::Result<(Option<String>, bool)> {
|
|
403
|
+
let row = client.query_opt("SELECT cursor, completed FROM hecks_backfill_progress WHERE target = $1", &[&target]).await?;
|
|
404
|
+
Ok(match row {
|
|
405
|
+
None => (None, false),
|
|
406
|
+
Some(row) => (row.get("cursor"), row.get("completed")),
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async fn upsert_backfill_progress<C: GenericClient>(client: &C, target: &str, cursor: Option<&str>, completed: bool) -> anyhow::Result<()> {
|
|
411
|
+
client
|
|
412
|
+
.execute(
|
|
413
|
+
"INSERT INTO hecks_backfill_progress (target, cursor, completed, updated_at) VALUES ($1, $2, $3, now()) \
|
|
414
|
+
ON CONFLICT (target) DO UPDATE SET cursor = EXCLUDED.cursor, completed = EXCLUDED.completed, updated_at = EXCLUDED.updated_at",
|
|
415
|
+
&[&target, &cursor, &completed],
|
|
416
|
+
)
|
|
417
|
+
.await?;
|
|
418
|
+
Ok(())
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/// One chunk of `head_snapshot`'s backfill, SAVEPOINT-nested since this
|
|
422
|
+
/// always runs inside the mint transaction `mint_era` already opened.
|
|
423
|
+
async fn run_backfill_chunk<C: GenericClient>(client: &C, domain: &str, storage_name: &str, era: i32, target: &str) -> anyhow::Result<bool> {
|
|
424
|
+
client.batch_execute("SAVEPOINT hecks_backfill_chunk").await?;
|
|
425
|
+
let result: anyhow::Result<bool> = async {
|
|
426
|
+
client.execute("SELECT pg_advisory_xact_lock(hashtext('hecks_field_cache:' || $1))", &[&target]).await?;
|
|
427
|
+
let (cursor, completed) = backfill_progress(client, target).await?;
|
|
428
|
+
if completed {
|
|
429
|
+
return Ok(true);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
let journal = journal_table(domain);
|
|
433
|
+
let cursor_clause = cursor.as_deref().map(|c| format!(" AND aggregate_id > {}", text_literal(c))).unwrap_or_default();
|
|
434
|
+
let source_sql = format!(
|
|
435
|
+
"SELECT id, ordinal, state FROM (\
|
|
436
|
+
SELECT DISTINCT ON (aggregate_id) aggregate_id AS id, ordinal, operation, state \
|
|
437
|
+
FROM {} WHERE era = {era} AND aggregate = {}{cursor_clause} \
|
|
438
|
+
ORDER BY aggregate_id, ordinal DESC\
|
|
439
|
+
) latest WHERE operation = 'save' ORDER BY id LIMIT {CHUNK_SIZE}",
|
|
440
|
+
quote_ident(&journal),
|
|
441
|
+
text_literal(storage_name)
|
|
442
|
+
);
|
|
443
|
+
let rows = client.query(&source_sql, &[]).await?;
|
|
444
|
+
if rows.is_empty() {
|
|
445
|
+
upsert_backfill_progress(client, target, cursor.as_deref(), true).await?;
|
|
446
|
+
return Ok(true);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
let quoted_target = quote_ident(target);
|
|
450
|
+
for row in &rows {
|
|
451
|
+
let id: String = row.get("id");
|
|
452
|
+
let ordinal: i64 = row.get("ordinal");
|
|
453
|
+
let state: serde_json::Value = row.get("state");
|
|
454
|
+
client
|
|
455
|
+
.execute(
|
|
456
|
+
&format!(
|
|
457
|
+
"INSERT INTO {quoted_target} (id, ordinal, state) VALUES ($1, $2, $3) \
|
|
458
|
+
ON CONFLICT (id) DO UPDATE SET ordinal = EXCLUDED.ordinal, state = EXCLUDED.state \
|
|
459
|
+
WHERE {quoted_target}.ordinal < EXCLUDED.ordinal"
|
|
460
|
+
),
|
|
461
|
+
&[&id, &ordinal, &state],
|
|
462
|
+
)
|
|
463
|
+
.await?;
|
|
464
|
+
}
|
|
465
|
+
let done = (rows.len() as i64) < CHUNK_SIZE;
|
|
466
|
+
let last_cursor: String = rows.last().unwrap().get("id");
|
|
467
|
+
upsert_backfill_progress(client, target, Some(&last_cursor), done).await?;
|
|
468
|
+
Ok(done)
|
|
469
|
+
}
|
|
470
|
+
.await;
|
|
471
|
+
|
|
472
|
+
match &result {
|
|
473
|
+
Ok(_) => client.batch_execute("RELEASE SAVEPOINT hecks_backfill_chunk").await?,
|
|
474
|
+
Err(_) => client.batch_execute("ROLLBACK TO SAVEPOINT hecks_backfill_chunk").await?,
|
|
475
|
+
}
|
|
476
|
+
result
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async fn backfill_head_snapshot<C: GenericClient>(client: &C, domain: &str, storage_name: &str, era: i32) -> anyhow::Result<()> {
|
|
480
|
+
let target = head_snapshot(domain, storage_name, era);
|
|
481
|
+
loop {
|
|
482
|
+
if run_backfill_chunk(client, domain, storage_name, era, &target).await? {
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
Ok(())
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async fn table_exists<C: GenericClient>(client: &C, name: &str) -> anyhow::Result<bool> {
|
|
490
|
+
let row = client
|
|
491
|
+
.query_opt("SELECT 1 FROM pg_class WHERE relname = $1 AND relkind = 'r' AND pg_table_is_visible(oid)", &[&name])
|
|
492
|
+
.await?;
|
|
493
|
+
Ok(row.is_some())
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/// No backfill: adopting an era means its history already exists elsewhere.
|
|
497
|
+
/// Runs in its own transaction because `create_head_snapshot`'s SAVEPOINT needs one open.
|
|
498
|
+
pub(crate) async fn adopt_head_snapshots<C: GenericClient>(
|
|
499
|
+
client: &C,
|
|
500
|
+
domain: &str,
|
|
501
|
+
aggregates: &[Aggregate],
|
|
502
|
+
era: i32,
|
|
503
|
+
) -> anyhow::Result<()> {
|
|
504
|
+
client.batch_execute("BEGIN").await.context("opening a transaction to adopt head snapshots")?;
|
|
505
|
+
let result: anyhow::Result<()> = async {
|
|
506
|
+
for aggregate in aggregates {
|
|
507
|
+
create_head_snapshot(client, domain, &aggregate.storage_name, era).await.with_context(|| {
|
|
508
|
+
format!("provisioning {domain}'s {} head snapshot for era {era}", aggregate.storage_name)
|
|
509
|
+
})?;
|
|
510
|
+
}
|
|
511
|
+
Ok(())
|
|
512
|
+
}
|
|
513
|
+
.await;
|
|
514
|
+
match &result {
|
|
515
|
+
Ok(_) => client.batch_execute("COMMIT").await?,
|
|
516
|
+
Err(_) => client.batch_execute("ROLLBACK").await?,
|
|
517
|
+
}
|
|
518
|
+
result
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async fn ensure_head_snapshot<C: GenericClient>(client: &C, domain: &str, storage_name: &str, era: i32) -> anyhow::Result<()> {
|
|
522
|
+
create_head_snapshot(client, domain, storage_name, era).await?;
|
|
523
|
+
backfill_head_snapshot(client, domain, storage_name, era).await
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/// The create half, shared by minting and adopting. Columns match Ruby's
|
|
527
|
+
/// own shape, so a Ruby runtime can still compile a head view over this table.
|
|
528
|
+
async fn create_head_snapshot<C: GenericClient>(client: &C, domain: &str, storage_name: &str, era: i32) -> anyhow::Result<()> {
|
|
529
|
+
let name = head_snapshot(domain, storage_name, era);
|
|
530
|
+
if !table_exists(client, &name).await? {
|
|
531
|
+
client.batch_execute("SAVEPOINT hecks_head_snapshot").await?;
|
|
532
|
+
let result: anyhow::Result<()> = async {
|
|
533
|
+
client.execute("SELECT pg_advisory_xact_lock(hashtext('hecks_head_snapshot:' || $1))", &[&name]).await?;
|
|
534
|
+
if !table_exists(client, &name).await? {
|
|
535
|
+
client
|
|
536
|
+
.batch_execute(&format!(
|
|
537
|
+
"CREATE TABLE {} (id text PRIMARY KEY, ordinal bigint NOT NULL, \
|
|
538
|
+
operation text NOT NULL DEFAULT 'save', state jsonb)",
|
|
539
|
+
quote_ident(&name)
|
|
540
|
+
))
|
|
541
|
+
.await?;
|
|
542
|
+
}
|
|
543
|
+
Ok(())
|
|
544
|
+
}
|
|
545
|
+
.await;
|
|
546
|
+
match &result {
|
|
547
|
+
Ok(_) => client.batch_execute("RELEASE SAVEPOINT hecks_head_snapshot").await?,
|
|
548
|
+
Err(_) => client.batch_execute("ROLLBACK TO SAVEPOINT hecks_head_snapshot").await?,
|
|
549
|
+
}
|
|
550
|
+
result?;
|
|
551
|
+
}
|
|
552
|
+
Ok(())
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/// Which name/storage_name each aggregate carried at each era in the
|
|
556
|
+
/// chain, walked backward from the current (era N) name through each edge's `was:`.
|
|
557
|
+
fn names_by_era(current_name: &str, edges: &[&Edge]) -> (Vec<String>, Vec<String>) {
|
|
558
|
+
let mut current = vec![String::new(); edges.len() + 1];
|
|
559
|
+
current[edges.len()] = current_name.to_string();
|
|
560
|
+
for index in (0..edges.len()).rev() {
|
|
561
|
+
let declared = edges[index].for_aggregate(¤t[index + 1]);
|
|
562
|
+
current[index] = declared.and_then(|d| d.was.clone()).unwrap_or_else(|| current[index + 1].clone());
|
|
563
|
+
}
|
|
564
|
+
let storage = current.iter().map(|name| storage_shape_snake(name)).collect();
|
|
565
|
+
(current, storage)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
fn ancestor_tail_sql(domain: &str, era: i32, storage_names: &[String], watermarks: &std::collections::HashMap<i32, Option<i64>>) -> String {
|
|
569
|
+
let journal = quote_ident(&journal_table(domain));
|
|
570
|
+
(1..era)
|
|
571
|
+
.map(|ancestor| {
|
|
572
|
+
let cut = watermarks.get(&(ancestor + 1)).copied().flatten();
|
|
573
|
+
let cut_clause = cut.map(|c| format!(" AND ordinal <= {c}")).unwrap_or_default();
|
|
574
|
+
format!(
|
|
575
|
+
"SELECT ordinal, era, aggregate, aggregate_id, operation, state FROM {journal} \
|
|
576
|
+
WHERE era = {ancestor} AND aggregate = {}{cut_clause}",
|
|
577
|
+
text_literal(&storage_names[(ancestor - 1) as usize])
|
|
578
|
+
)
|
|
579
|
+
})
|
|
580
|
+
.collect::<Vec<_>>()
|
|
581
|
+
.join(" UNION ALL ")
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
fn latest_per_id(tail: &str) -> String {
|
|
585
|
+
if tail.is_empty() {
|
|
586
|
+
return tail.to_string();
|
|
587
|
+
}
|
|
588
|
+
format!("SELECT DISTINCT ON (aggregate_id) ordinal, era, aggregate, aggregate_id, operation, state FROM ({tail}) tail_entries ORDER BY aggregate_id, ordinal DESC")
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Each edge CTE is `MATERIALIZED` — unfenced, Postgres re-inlines every edge's
|
|
592
|
+
// expression at each read of `state`, and the plan grows exponentially with the chain.
|
|
593
|
+
fn chain_sql(domain: &str, current_name: &str, era: i32, edges: &[&Edge], watermarks: &std::collections::HashMap<i32, Option<i64>>) -> String {
|
|
594
|
+
chain_sql_with(domain, current_name, era, edges, watermarks, true)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
fn chain_sql_with(
|
|
598
|
+
domain: &str,
|
|
599
|
+
current_name: &str,
|
|
600
|
+
era: i32,
|
|
601
|
+
edges: &[&Edge],
|
|
602
|
+
watermarks: &std::collections::HashMap<i32, Option<i64>>,
|
|
603
|
+
materialize_edges: bool,
|
|
604
|
+
) -> String {
|
|
605
|
+
let fence = if materialize_edges { "MATERIALIZED " } else { "" };
|
|
606
|
+
let (current_names, storage_names) = names_by_era(current_name, edges);
|
|
607
|
+
let tail = latest_per_id(&ancestor_tail_sql(domain, era, &storage_names, watermarks));
|
|
608
|
+
|
|
609
|
+
let chain: Vec<String> = edges
|
|
610
|
+
.iter()
|
|
611
|
+
.enumerate()
|
|
612
|
+
.map(|(index, edge)| {
|
|
613
|
+
let declared = edge.for_aggregate(¤t_names[index + 1]);
|
|
614
|
+
let expression = declared.map(|d| d.compiled_state_expression.clone()).unwrap_or_else(|| "state".to_string());
|
|
615
|
+
let guard = format!("era <= {} AND operation = 'save'", index + 1);
|
|
616
|
+
let id_column = match declared.filter(|d| d.compiled_id_expression.is_some()) {
|
|
617
|
+
Some(d) => format!(
|
|
618
|
+
"CASE WHEN {guard} THEN {} ELSE aggregate_id END AS aggregate_id",
|
|
619
|
+
d.compiled_id_expression.as_deref().unwrap()
|
|
620
|
+
),
|
|
621
|
+
None => "aggregate_id".to_string(),
|
|
622
|
+
};
|
|
623
|
+
let from = if index == 0 { "tail".to_string() } else { format!("edge_{index}") };
|
|
624
|
+
format!(
|
|
625
|
+
"edge_{} AS {fence}(SELECT ordinal, era, {id_column}, operation, CASE WHEN {guard} THEN {expression} ELSE state END AS state FROM {from})",
|
|
626
|
+
index + 1
|
|
627
|
+
)
|
|
628
|
+
})
|
|
629
|
+
.collect();
|
|
630
|
+
|
|
631
|
+
format!("WITH tail AS {fence}({tail}),\n{}\nSELECT ordinal, aggregate_id, operation, state FROM edge_{}", chain.join(",\n"), edges.len())
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/// Reduces a chain SQL to one row per id: the latest entry, only if it was a save.
|
|
635
|
+
async fn latest_of<C: GenericClient>(client: &C, inner_sql: &str) -> anyhow::Result<std::collections::HashMap<String, Value>> {
|
|
636
|
+
let sql = format!(
|
|
637
|
+
"SELECT aggregate_id, state FROM (\
|
|
638
|
+
SELECT DISTINCT ON (aggregate_id) aggregate_id, operation, state \
|
|
639
|
+
FROM ({inner_sql}) chained ORDER BY aggregate_id, ordinal DESC\
|
|
640
|
+
) latest WHERE operation = 'save'"
|
|
641
|
+
);
|
|
642
|
+
let rows = client.query(&sql, &[]).await?;
|
|
643
|
+
Ok(rows.into_iter().map(|row| (row.get::<_, String>("aggregate_id"), row.get::<_, Value>("state"))).collect())
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/// The translated tail at era `era`, latest per id, saves only — the audit's `after`.
|
|
647
|
+
async fn translated_latest<C: GenericClient>(
|
|
648
|
+
client: &C,
|
|
649
|
+
domain: &str,
|
|
650
|
+
current_name: &str,
|
|
651
|
+
era: i32,
|
|
652
|
+
edges: &[&Edge],
|
|
653
|
+
watermarks: &std::collections::HashMap<i32, Option<i64>>,
|
|
654
|
+
) -> anyhow::Result<std::collections::HashMap<String, Value>> {
|
|
655
|
+
latest_of(client, &chain_sql(domain, current_name, era, edges, watermarks)).await
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/// The untranslated ancestor tail, latest per id — the "before" side of the
|
|
659
|
+
/// preservation check. Era 1 has no ancestors, so the tail SQL is empty.
|
|
660
|
+
async fn ancestor_latest<C: GenericClient>(
|
|
661
|
+
client: &C,
|
|
662
|
+
domain: &str,
|
|
663
|
+
current_name: &str,
|
|
664
|
+
era: i32,
|
|
665
|
+
edges: &[&Edge],
|
|
666
|
+
watermarks: &std::collections::HashMap<i32, Option<i64>>,
|
|
667
|
+
) -> anyhow::Result<std::collections::HashMap<String, Value>> {
|
|
668
|
+
let (_, storage_names) = names_by_era(current_name, edges);
|
|
669
|
+
let tail = ancestor_tail_sql(domain, era, &storage_names, watermarks);
|
|
670
|
+
if tail.is_empty() {
|
|
671
|
+
return Ok(std::collections::HashMap::new());
|
|
672
|
+
}
|
|
673
|
+
latest_of(client, &format!("SELECT ordinal, era, aggregate_id, operation, state FROM ({tail}) tail_rows")).await
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/// One aggregate's raw rule JSON within one edge; `reference_transform::translate`
|
|
677
|
+
/// needs the declarative rules themselves, not just the compiled SQL.
|
|
678
|
+
fn raw_declared<'a>(raw_edges: &'a [Value], domain: &str, edge: &Edge, aggregate_name: &str) -> Option<&'a Value> {
|
|
679
|
+
raw_edges
|
|
680
|
+
.iter()
|
|
681
|
+
.find(|candidate| {
|
|
682
|
+
candidate.get("domain").and_then(Value::as_str) == Some(domain)
|
|
683
|
+
&& candidate.get("from").and_then(Value::as_str) == Some(edge.from.as_str())
|
|
684
|
+
&& candidate.get("to").and_then(Value::as_str) == Some(edge.to.as_str())
|
|
685
|
+
})
|
|
686
|
+
.and_then(|raw_edge| raw_edge.get("aggregates").and_then(Value::as_array))
|
|
687
|
+
.and_then(|aggs| aggs.iter().find(|agg| agg.get("name").and_then(Value::as_str) == Some(aggregate_name)))
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/// Layer 2 of the mint audit for one aggregate: per-rule value preservation
|
|
691
|
+
/// plus id-set/count conservation across the edge. Appends to `violations`
|
|
692
|
+
/// rather than raising, so one mint reports everything wrong at once.
|
|
693
|
+
///
|
|
694
|
+
/// `declared` is the raw rule JSON for the edge being minted this boot only —
|
|
695
|
+
/// earlier edges in a multi-hop drift were already minted and audited.
|
|
696
|
+
async fn audit_layer_two<C: GenericClient>(
|
|
697
|
+
client: &C,
|
|
698
|
+
domain: &str,
|
|
699
|
+
aggregate: &Aggregate,
|
|
700
|
+
ordinal: i32,
|
|
701
|
+
edges: &[&Edge],
|
|
702
|
+
raw_edges: &[Value],
|
|
703
|
+
watermarks: &std::collections::HashMap<i32, Option<i64>>,
|
|
704
|
+
after: &std::collections::HashMap<String, Value>,
|
|
705
|
+
violations: &mut Vec<String>,
|
|
706
|
+
) -> anyhow::Result<()> {
|
|
707
|
+
let before = if edges.len() > 1 {
|
|
708
|
+
translated_latest(client, domain, &aggregate.name, ordinal, &edges[..edges.len() - 1], watermarks).await?
|
|
709
|
+
} else {
|
|
710
|
+
ancestor_latest(client, domain, &aggregate.name, ordinal, edges, watermarks).await?
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
let last_edge = edges.last().expect("mint always audits at least one edge");
|
|
714
|
+
let declared = raw_declared(raw_edges, domain, last_edge, &aggregate.name);
|
|
715
|
+
let rekeyed = declared.map(|d| !d.get("rekeys").and_then(Value::as_array).map(Vec::is_empty).unwrap_or(true)).unwrap_or(false);
|
|
716
|
+
|
|
717
|
+
if rekeyed {
|
|
718
|
+
if before.len() != after.len() {
|
|
719
|
+
violations.push(format!(
|
|
720
|
+
"{}: the record count changed across a rekeying edge ({} before, {} after) — a rekey must not \
|
|
721
|
+
collide two distinct ids onto one, or drop one",
|
|
722
|
+
aggregate.name,
|
|
723
|
+
before.len(),
|
|
724
|
+
after.len()
|
|
725
|
+
));
|
|
726
|
+
}
|
|
727
|
+
} else {
|
|
728
|
+
let mut lost: Vec<&String> = before.keys().filter(|id| !after.contains_key(*id)).collect();
|
|
729
|
+
let mut gained: Vec<&String> = after.keys().filter(|id| !before.contains_key(*id)).collect();
|
|
730
|
+
if !lost.is_empty() || !gained.is_empty() {
|
|
731
|
+
lost.sort();
|
|
732
|
+
gained.sort();
|
|
733
|
+
violations.push(format!(
|
|
734
|
+
"{}: the id set changed across the edge (lost {:?}, gained {:?})",
|
|
735
|
+
aggregate.name, lost, gained
|
|
736
|
+
));
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
let Some(declared) = declared else { return Ok(()) };
|
|
741
|
+
if rekeyed {
|
|
742
|
+
return Ok(());
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
let compute_tops: std::collections::HashSet<String> = declared
|
|
746
|
+
.get("computes")
|
|
747
|
+
.and_then(Value::as_array)
|
|
748
|
+
.map(|computes| {
|
|
749
|
+
computes
|
|
750
|
+
.iter()
|
|
751
|
+
.flat_map(|c| [c.get("from"), c.get("to")])
|
|
752
|
+
.flatten()
|
|
753
|
+
.filter_map(Value::as_str)
|
|
754
|
+
.map(|path| path.split('.').next().unwrap_or(path).to_string())
|
|
755
|
+
.collect()
|
|
756
|
+
})
|
|
757
|
+
.unwrap_or_default();
|
|
758
|
+
|
|
759
|
+
for (id, state) in &before {
|
|
760
|
+
let Some(actual_after) = after.get(id) else { continue };
|
|
761
|
+
let expected = reference_transform::translate(declared, state)?;
|
|
762
|
+
let expected = strip_keys(&expected, &compute_tops);
|
|
763
|
+
let actual = strip_keys(actual_after, &compute_tops);
|
|
764
|
+
if expected != actual {
|
|
765
|
+
let mut diverged: Vec<String> = expected
|
|
766
|
+
.as_object()
|
|
767
|
+
.into_iter()
|
|
768
|
+
.flat_map(|o| o.keys())
|
|
769
|
+
.chain(actual.as_object().into_iter().flat_map(|o| o.keys()))
|
|
770
|
+
.filter(|key| expected.get(key.as_str()) != actual.get(key.as_str()))
|
|
771
|
+
.cloned()
|
|
772
|
+
.collect();
|
|
773
|
+
diverged.sort();
|
|
774
|
+
diverged.dedup();
|
|
775
|
+
violations.push(format!("{}#{id}: the translated state diverges from the reference transform at {}", aggregate.name, diverged.join(", ")));
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
Ok(())
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
fn strip_keys(state: &Value, keys: &std::collections::HashSet<String>) -> Value {
|
|
782
|
+
match state {
|
|
783
|
+
Value::Object(map) => Value::Object(map.iter().filter(|(k, _)| !keys.contains(*k)).map(|(k, v)| (k.clone(), v.clone())).collect()),
|
|
784
|
+
other => other.clone(),
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/// The whole mint-time audit, over every lineage-capable aggregate. Runs
|
|
789
|
+
/// before anything is minted, so a refusal leaves no half-born era.
|
|
790
|
+
///
|
|
791
|
+
/// `ir` is the whole `ir.json` value; Layer 1 looks up each aggregate's node from it.
|
|
792
|
+
pub async fn audit_before_mint<C: GenericClient>(
|
|
793
|
+
client: &C,
|
|
794
|
+
domain: &str,
|
|
795
|
+
ir: &Value,
|
|
796
|
+
aggregates: &[Aggregate],
|
|
797
|
+
ordinal: i32,
|
|
798
|
+
edges: &[&Edge],
|
|
799
|
+
raw_edges: &[Value],
|
|
800
|
+
watermarks: &std::collections::HashMap<i32, Option<i64>>,
|
|
801
|
+
) -> anyhow::Result<()> {
|
|
802
|
+
let ir_aggregates: std::collections::HashMap<&str, &Value> = ir
|
|
803
|
+
.get("aggregates")
|
|
804
|
+
.and_then(Value::as_array)
|
|
805
|
+
.map(|list| list.iter().filter_map(|agg| agg.get("name").and_then(Value::as_str).map(|n| (n, agg))).collect())
|
|
806
|
+
.unwrap_or_default();
|
|
807
|
+
|
|
808
|
+
let mut violations = Vec::new();
|
|
809
|
+
for aggregate in aggregates {
|
|
810
|
+
let phase = crate::log::phase_with("audit_aggregate", serde_json::json!({ "aggregate": &aggregate.storage_name }));
|
|
811
|
+
let after = translated_latest(client, domain, &aggregate.name, ordinal, edges, watermarks).await?;
|
|
812
|
+
|
|
813
|
+
// Layer 1 — structural (types/patterns/admits/lifecycle) — over
|
|
814
|
+
// every record `after` actually holds, matching Ruby's own
|
|
815
|
+
// `layer_one!(violations, aggregate, after)` exactly.
|
|
816
|
+
if let Some(aggregate_ir) = ir_aggregates.get(aggregate.name.as_str()) {
|
|
817
|
+
for (id, state) in &after {
|
|
818
|
+
violations.extend(reference_validate::validate(aggregate_ir, id, state));
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Layer 2 — per-rule value preservation against the reference
|
|
823
|
+
// transform, id-set/count conservation.
|
|
824
|
+
audit_layer_two(client, domain, aggregate, ordinal, edges, raw_edges, watermarks, &after, &mut violations).await?;
|
|
825
|
+
phase.end_with(serde_json::json!({ "records": after.len() }));
|
|
826
|
+
}
|
|
827
|
+
if violations.is_empty() {
|
|
828
|
+
return Ok(());
|
|
829
|
+
}
|
|
830
|
+
anyhow::bail!("cannot mint era {ordinal} of {domain}: the audit refused —\n - {}", violations.join("\n - "));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/// `compile_head!` — one aggregate's matview + head-snapshot + head
|
|
834
|
+
/// view, for one era, over the full (never layered) chain.
|
|
835
|
+
async fn compile_head<C: GenericClient>(
|
|
836
|
+
client: &C,
|
|
837
|
+
domain: &str,
|
|
838
|
+
aggregate: &Aggregate,
|
|
839
|
+
era: i32,
|
|
840
|
+
label: &str,
|
|
841
|
+
edges: &[&Edge],
|
|
842
|
+
watermarks: &std::collections::HashMap<i32, Option<i64>>,
|
|
843
|
+
) -> anyhow::Result<()> {
|
|
844
|
+
let storage_name = &aggregate.storage_name;
|
|
845
|
+
let view = matview(domain, storage_name, era, label);
|
|
846
|
+
let body = chain_sql(domain, &aggregate.name, era, edges, watermarks);
|
|
847
|
+
|
|
848
|
+
client.batch_execute(&format!("CREATE MATERIALIZED VIEW {} AS\n{body}", quote_ident(&view))).await?;
|
|
849
|
+
client
|
|
850
|
+
.batch_execute(&format!(
|
|
851
|
+
"CREATE INDEX IF NOT EXISTS {} ON {} (aggregate_id, ordinal DESC)",
|
|
852
|
+
quote_ident(&format!("{view}_reduce_idx")),
|
|
853
|
+
quote_ident(&view)
|
|
854
|
+
))
|
|
855
|
+
.await?;
|
|
856
|
+
|
|
857
|
+
ensure_head_snapshot(client, domain, storage_name, era).await?;
|
|
858
|
+
|
|
859
|
+
client.batch_execute(&format!("DROP VIEW IF EXISTS {}", quote_ident(&head_view(domain, storage_name)))).await?;
|
|
860
|
+
client
|
|
861
|
+
.batch_execute(&format!(
|
|
862
|
+
"CREATE VIEW {} AS \
|
|
863
|
+
SELECT id, state FROM (\
|
|
864
|
+
SELECT DISTINCT ON (aggregate_id) aggregate_id AS id, operation, state FROM (\
|
|
865
|
+
SELECT ordinal, aggregate_id, operation, state FROM {} \
|
|
866
|
+
UNION ALL \
|
|
867
|
+
SELECT ordinal, id AS aggregate_id, 'save' AS operation, state FROM {}\
|
|
868
|
+
) merged ORDER BY aggregate_id, ordinal DESC\
|
|
869
|
+
) latest WHERE operation = 'save'",
|
|
870
|
+
quote_ident(&head_view(domain, storage_name)),
|
|
871
|
+
quote_ident(&view),
|
|
872
|
+
quote_ident(&head_snapshot(domain, storage_name, era))
|
|
873
|
+
))
|
|
874
|
+
.await?;
|
|
875
|
+
Ok(())
|
|
876
|
+
}
|
|
877
|
+
/// Mints era 1: fresh domain, no edge, never needs the approval gate.
|
|
878
|
+
pub async fn hold_first<C: GenericClient>(client: &C, domain: &str, held_text: &str, ir: &Value, aggregates: &[Aggregate], role: Option<&str>) -> anyhow::Result<()> {
|
|
879
|
+
ensure_base(client, domain).await?;
|
|
880
|
+
|
|
881
|
+
// Own transaction: `ensure_first_head`'s `ensure_head_snapshot` backfill
|
|
882
|
+
// loop is SAVEPOINT-shaped and needs one open.
|
|
883
|
+
client.batch_execute("BEGIN").await?;
|
|
884
|
+
let result = hold_first_body(client, domain, held_text, ir, aggregates, role).await;
|
|
885
|
+
match &result {
|
|
886
|
+
Ok(_) => client.batch_execute("COMMIT").await?,
|
|
887
|
+
Err(_) => {
|
|
888
|
+
let _ = client.batch_execute("ROLLBACK").await;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
result
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async fn hold_first_body<C: GenericClient>(client: &C, domain: &str, held_text: &str, ir: &Value, aggregates: &[Aggregate], role: Option<&str>) -> anyhow::Result<()> {
|
|
895
|
+
let hash = storage_shape::mint_hash(ir);
|
|
896
|
+
let label = storage_shape::mint_label(ir);
|
|
897
|
+
let digest = format!("{:x}", Sha256::digest(held_text.as_bytes()));
|
|
898
|
+
|
|
899
|
+
client
|
|
900
|
+
.execute(
|
|
901
|
+
"INSERT INTO hecks_eras (domain, ordinal, hash, label, held_text, watermark, held_digest, canon_form) \
|
|
902
|
+
VALUES ($1, 1, $2, $3, $4, 0, $5, $6) ON CONFLICT DO NOTHING",
|
|
903
|
+
&[&domain, &hash, &label, &held_text, &digest, &storage_shape::FORM_VERSION],
|
|
904
|
+
)
|
|
905
|
+
.await?;
|
|
906
|
+
archive_text(client, domain, 1, held_text, &digest).await?;
|
|
907
|
+
for aggregate in aggregates {
|
|
908
|
+
ensure_first_head(client, domain, &aggregate.storage_name).await?;
|
|
909
|
+
}
|
|
910
|
+
advance_era(client, domain, 1).await?;
|
|
911
|
+
if let Some(role) = role {
|
|
912
|
+
grant_role(client, domain, role, &[], None).await?;
|
|
913
|
+
}
|
|
914
|
+
Ok(())
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/// `ensure_first_head!` — era 1's head is a plain view over its own
|
|
918
|
+
/// (immediately-created) snapshot table, no chain to compile, no
|
|
919
|
+
/// translation possible from nothing.
|
|
920
|
+
async fn ensure_first_head<C: GenericClient>(client: &C, domain: &str, storage_name: &str) -> anyhow::Result<()> {
|
|
921
|
+
ensure_head_snapshot(client, domain, storage_name, 1).await?;
|
|
922
|
+
client
|
|
923
|
+
.batch_execute(&format!(
|
|
924
|
+
"CREATE OR REPLACE VIEW {} AS SELECT id, state FROM {}",
|
|
925
|
+
quote_ident(&head_view(domain, storage_name)),
|
|
926
|
+
quote_ident(&head_snapshot(domain, storage_name, 1))
|
|
927
|
+
))
|
|
928
|
+
.await?;
|
|
929
|
+
Ok(())
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async fn archive_text<C: GenericClient>(client: &C, domain: &str, ordinal: i32, text: &str, digest: &str) -> anyhow::Result<()> {
|
|
933
|
+
client
|
|
934
|
+
.execute(
|
|
935
|
+
"INSERT INTO hecks_era_texts (domain, ordinal, digest, held_text) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
|
|
936
|
+
&[&domain, &ordinal, &digest, &text],
|
|
937
|
+
)
|
|
938
|
+
.await?;
|
|
939
|
+
Ok(())
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
pub(crate) async fn advance_era<C: GenericClient>(client: &C, domain: &str, ordinal: i32) -> anyhow::Result<()> {
|
|
943
|
+
let journal = quote_ident(&journal_table(domain));
|
|
944
|
+
client.batch_execute(&format!("DROP POLICY IF EXISTS hecks_current_era ON {journal}")).await?;
|
|
945
|
+
client
|
|
946
|
+
.batch_execute(&format!("CREATE POLICY hecks_current_era ON {journal} FOR INSERT TO PUBLIC WITH CHECK (era = {ordinal})"))
|
|
947
|
+
.await?;
|
|
948
|
+
client.batch_execute(&format!("DROP POLICY IF EXISTS hecks_read_all ON {journal}")).await?;
|
|
949
|
+
client.batch_execute(&format!("CREATE POLICY hecks_read_all ON {journal} FOR SELECT TO PUBLIC USING (true)")).await?;
|
|
950
|
+
Ok(())
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async fn grant_role<C: GenericClient>(client: &C, domain: &str, role: &str, aggregates: &[Aggregate], era: Option<i32>) -> anyhow::Result<()> {
|
|
954
|
+
let journal = journal_table(domain);
|
|
955
|
+
if !provisioner(client, &journal).await? {
|
|
956
|
+
return Ok(());
|
|
957
|
+
}
|
|
958
|
+
let quoted_role = quote_ident(role);
|
|
959
|
+
client.batch_execute(&format!("GRANT INSERT, SELECT ON {} TO {quoted_role}", quote_ident(&journal))).await?;
|
|
960
|
+
client.batch_execute(&format!("GRANT USAGE ON SEQUENCE {} TO {quoted_role}", quote_ident(&sequence(domain)))).await?;
|
|
961
|
+
let Some(era) = era else { return Ok(()) };
|
|
962
|
+
for aggregate in aggregates {
|
|
963
|
+
let storage_name = &aggregate.storage_name;
|
|
964
|
+
client
|
|
965
|
+
.batch_execute(&format!(
|
|
966
|
+
"GRANT SELECT, INSERT, UPDATE, DELETE ON {} TO {quoted_role}",
|
|
967
|
+
quote_ident(&head_snapshot(domain, storage_name, era))
|
|
968
|
+
))
|
|
969
|
+
.await?;
|
|
970
|
+
let view = head_view(domain, storage_name);
|
|
971
|
+
if table_or_view_exists(client, &view).await? {
|
|
972
|
+
client.batch_execute(&format!("GRANT SELECT ON {} TO {quoted_role}", quote_ident(&view))).await?;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
Ok(())
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async fn table_or_view_exists<C: GenericClient>(client: &C, name: &str) -> anyhow::Result<bool> {
|
|
979
|
+
let row = client
|
|
980
|
+
.query_opt("SELECT 1 FROM pg_class WHERE relname = $1 AND relkind IN ('v', 'm') AND pg_table_is_visible(oid)", &[&name])
|
|
981
|
+
.await?;
|
|
982
|
+
Ok(row.is_some())
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/// The one transaction that makes era `ordinal` real. Approval-gate checking
|
|
986
|
+
/// is the caller's job; this function mints once asked to.
|
|
987
|
+
#[allow(clippy::too_many_arguments)]
|
|
988
|
+
pub async fn mint_era<C: GenericClient>(
|
|
989
|
+
client: &C,
|
|
990
|
+
domain: &str,
|
|
991
|
+
ordinal: i32,
|
|
992
|
+
hash: &str,
|
|
993
|
+
label: &str,
|
|
994
|
+
held_text: &str,
|
|
995
|
+
aggregates: &[Aggregate],
|
|
996
|
+
edges: &[&Edge],
|
|
997
|
+
role: Option<&str>,
|
|
998
|
+
lifecycle_defaults: &[LifecycleDefault],
|
|
999
|
+
) -> anyhow::Result<()> {
|
|
1000
|
+
client.batch_execute("BEGIN").await?;
|
|
1001
|
+
let result = mint_era_body(client, domain, ordinal, hash, label, held_text, aggregates, edges, role, lifecycle_defaults).await;
|
|
1002
|
+
match &result {
|
|
1003
|
+
Ok(_) => {
|
|
1004
|
+
let phase = crate::log::phase_with("mint_commit", serde_json::json!({ "era": ordinal }));
|
|
1005
|
+
client.batch_execute("COMMIT").await?;
|
|
1006
|
+
phase.end();
|
|
1007
|
+
}
|
|
1008
|
+
Err(_) => {
|
|
1009
|
+
let _ = client.batch_execute("ROLLBACK").await;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
result
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
#[allow(clippy::too_many_arguments)]
|
|
1016
|
+
async fn mint_era_body<C: GenericClient>(
|
|
1017
|
+
client: &C,
|
|
1018
|
+
domain: &str,
|
|
1019
|
+
ordinal: i32,
|
|
1020
|
+
hash: &str,
|
|
1021
|
+
label: &str,
|
|
1022
|
+
held_text: &str,
|
|
1023
|
+
aggregates: &[Aggregate],
|
|
1024
|
+
edges: &[&Edge],
|
|
1025
|
+
role: Option<&str>,
|
|
1026
|
+
lifecycle_defaults: &[LifecycleDefault],
|
|
1027
|
+
) -> anyhow::Result<()> {
|
|
1028
|
+
client.batch_execute("SET LOCAL lock_timeout = '10s'").await?;
|
|
1029
|
+
client
|
|
1030
|
+
.execute(&format!("SELECT pg_advisory_xact_lock(hashtext('hecks_eras:' || {}))", text_literal(domain)), &[])
|
|
1031
|
+
.await?;
|
|
1032
|
+
|
|
1033
|
+
let already_minted = client.query_opt("SELECT 1 FROM hecks_eras WHERE domain = $1 AND ordinal = $2", &[&domain, &ordinal]).await?;
|
|
1034
|
+
if already_minted.is_some() {
|
|
1035
|
+
anyhow::bail!("era {ordinal} of {domain} is already minted — nothing to do");
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
let watermark = crate::journal::last_ordinal(client, domain).await?;
|
|
1039
|
+
let digest = format!("{:x}", Sha256::digest(held_text.as_bytes()));
|
|
1040
|
+
client
|
|
1041
|
+
.execute(
|
|
1042
|
+
"INSERT INTO hecks_eras (domain, ordinal, hash, label, held_text, watermark, held_digest, canon_form) \
|
|
1043
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
|
|
1044
|
+
&[&domain, &ordinal, &hash, &label, &held_text, &watermark, &digest, &storage_shape::FORM_VERSION],
|
|
1045
|
+
)
|
|
1046
|
+
.await?;
|
|
1047
|
+
archive_text(client, domain, ordinal, held_text, &digest).await?;
|
|
1048
|
+
ensure_partition(client, domain, ordinal).await?;
|
|
1049
|
+
|
|
1050
|
+
// Every held era's watermark, for `ancestor_tail_sql`'s cut — includes
|
|
1051
|
+
// the row just inserted, which bounds era `ordinal - 1`'s tail read.
|
|
1052
|
+
let held = crate::journal::held_eras(client, domain).await?;
|
|
1053
|
+
let watermarks: std::collections::HashMap<i32, Option<i64>> = held.iter().map(|era| (era.ordinal, era.watermark)).collect();
|
|
1054
|
+
|
|
1055
|
+
for aggregate in aggregates {
|
|
1056
|
+
let phase = crate::log::phase_with("compile_head", serde_json::json!({ "aggregate": &aggregate.storage_name, "era": ordinal }));
|
|
1057
|
+
compile_head(client, domain, aggregate, ordinal, label, edges, &watermarks).await?;
|
|
1058
|
+
phase.end();
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Same transaction as the era row and the fence flip: a boot that sees
|
|
1062
|
+
// the new era also sees a snapshot the new shape can read.
|
|
1063
|
+
let phase = crate::log::phase_with("snapshot_fill", serde_json::json!({ "era": ordinal }));
|
|
1064
|
+
let filled = fill_snapshot_lifecycle_defaults(client, domain, lifecycle_defaults).await?;
|
|
1065
|
+
phase.end_with(serde_json::json!({ "instances_filled": filled }));
|
|
1066
|
+
|
|
1067
|
+
if let Some(role) = role {
|
|
1068
|
+
grant_role(client, domain, role, aggregates, Some(ordinal)).await?;
|
|
1069
|
+
}
|
|
1070
|
+
advance_era(client, domain, ordinal).await?;
|
|
1071
|
+
Ok(())
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/// A lifecycle field an aggregate's instances must carry, and its default
|
|
1075
|
+
/// for new instances. `key_prefix` matches the kernel's `instances()` dump key shape.
|
|
1076
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
1077
|
+
pub struct LifecycleDefault {
|
|
1078
|
+
pub key_prefix: String,
|
|
1079
|
+
pub field: String,
|
|
1080
|
+
pub default: String,
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/// Every aggregate's lifecycle field and default, read from `ir.json`.
|
|
1084
|
+
/// An aggregate with no lifecycle (or no string default) contributes nothing.
|
|
1085
|
+
pub fn lifecycle_defaults(ir: &Value) -> Vec<LifecycleDefault> {
|
|
1086
|
+
let domain_name = ir.get("name").and_then(Value::as_str).unwrap_or_default();
|
|
1087
|
+
ir.get("aggregates")
|
|
1088
|
+
.and_then(Value::as_array)
|
|
1089
|
+
.map(|aggregates| {
|
|
1090
|
+
aggregates
|
|
1091
|
+
.iter()
|
|
1092
|
+
.filter_map(|aggregate| {
|
|
1093
|
+
let name = aggregate.get("name")?.as_str()?;
|
|
1094
|
+
let lifecycle = aggregate.get("lifecycle")?;
|
|
1095
|
+
let field = lifecycle.get("field")?.as_str()?;
|
|
1096
|
+
let default = lifecycle.get("default")?.as_str()?;
|
|
1097
|
+
Some(LifecycleDefault {
|
|
1098
|
+
key_prefix: format!("{domain_name}::{name}#"),
|
|
1099
|
+
field: field.to_string(),
|
|
1100
|
+
default: default.to_string(),
|
|
1101
|
+
})
|
|
1102
|
+
})
|
|
1103
|
+
.collect()
|
|
1104
|
+
})
|
|
1105
|
+
.unwrap_or_default()
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/// Fills a missing lifecycle field into every seeded instance; returns how
|
|
1109
|
+
/// many changed. Idempotent, since the kernel's `from_json` requires the field on load.
|
|
1110
|
+
pub fn fill_lifecycle_defaults(seed: &mut Value, defaults: &[LifecycleDefault]) -> usize {
|
|
1111
|
+
let Some(instances) = seed.as_object_mut() else { return 0 };
|
|
1112
|
+
let mut changed = 0;
|
|
1113
|
+
for (key, instance) in instances.iter_mut() {
|
|
1114
|
+
let Some(default) = defaults.iter().find(|d| key.starts_with(&d.key_prefix)) else { continue };
|
|
1115
|
+
let Some(fields) = instance.as_object_mut() else { continue };
|
|
1116
|
+
if fields.get(&default.field).is_none_or(Value::is_null) {
|
|
1117
|
+
fields.insert(default.field.clone(), Value::String(default.default.clone()));
|
|
1118
|
+
changed += 1;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
changed
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/// Fills `defaults` into the stored kernel snapshot, inside the caller's
|
|
1125
|
+
/// transaction — without this, a request right after the mint can hit
|
|
1126
|
+
/// `invalid seed` on the old-shaped snapshot. Takes the same advisory lock a
|
|
1127
|
+
/// dispatch holds, so an in-flight dispatch saves first.
|
|
1128
|
+
async fn fill_snapshot_lifecycle_defaults<C: GenericClient>(client: &C, domain: &str, defaults: &[LifecycleDefault]) -> anyhow::Result<usize> {
|
|
1129
|
+
if defaults.is_empty() {
|
|
1130
|
+
return Ok(0);
|
|
1131
|
+
}
|
|
1132
|
+
let table_exists: bool = client.query_one("SELECT to_regclass('hecks_lambda_snapshot') IS NOT NULL", &[]).await?.get(0);
|
|
1133
|
+
if !table_exists {
|
|
1134
|
+
return Ok(0);
|
|
1135
|
+
}
|
|
1136
|
+
client.execute("SELECT pg_advisory_xact_lock(hashtext('hecks_lambda_journal.' || $1::text))", &[&domain]).await?;
|
|
1137
|
+
let Some(row) = client.query_opt("SELECT seed FROM hecks_lambda_snapshot FOR UPDATE", &[]).await? else { return Ok(0) };
|
|
1138
|
+
let mut seed: Value = row.get(0);
|
|
1139
|
+
let changed = fill_lifecycle_defaults(&mut seed, defaults);
|
|
1140
|
+
if changed > 0 {
|
|
1141
|
+
client.execute("UPDATE hecks_lambda_snapshot SET seed = $1", &[&seed]).await.context("filling lifecycle defaults into the snapshot")?;
|
|
1142
|
+
}
|
|
1143
|
+
Ok(changed)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
#[cfg(test)]
|
|
1147
|
+
mod tests {
|
|
1148
|
+
|
|
1149
|
+
/// A throwaway database of its own — `mint.rs` also compiles into
|
|
1150
|
+
/// `bin/mint_harness`, which has no `dispatch` module to borrow a helper from.
|
|
1151
|
+
async fn own_scratch_db(name: &str) -> tokio_postgres::Client {
|
|
1152
|
+
let (admin, connection) =
|
|
1153
|
+
tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls).await.expect("connect as admin");
|
|
1154
|
+
tokio::spawn(async move {
|
|
1155
|
+
let _ = connection.await;
|
|
1156
|
+
});
|
|
1157
|
+
let _ = admin.batch_execute(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)")).await;
|
|
1158
|
+
admin.batch_execute(&format!("CREATE DATABASE {name}")).await.expect("create scratch db");
|
|
1159
|
+
let (client, connection) = tokio_postgres::connect(&crate::test_pg::conninfo(&name), NoTls)
|
|
1160
|
+
.await
|
|
1161
|
+
.expect("connect to scratch db");
|
|
1162
|
+
tokio::spawn(async move {
|
|
1163
|
+
let _ = connection.await;
|
|
1164
|
+
});
|
|
1165
|
+
client
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/// Regression: a Ruby-minted era 2 left every domain-qualified head
|
|
1169
|
+
/// snapshot stopped at era 1, so the first write died on a missing relation.
|
|
1170
|
+
#[tokio::test]
|
|
1171
|
+
async fn adopting_an_era_someone_else_minted_provisions_the_head_snapshots_it_is_missing() {
|
|
1172
|
+
let guard = own_scratch_db("hecks_host_adopt_head_snapshots").await;
|
|
1173
|
+
// Era 1's snapshot only — exactly the shape a Ruby-minted era 2
|
|
1174
|
+
// leaves behind for this crate's own domain-qualified naming.
|
|
1175
|
+
super::adopt_head_snapshots(
|
|
1176
|
+
&guard,
|
|
1177
|
+
"SampleApp",
|
|
1178
|
+
&[super::Aggregate { name: "StateStyle".to_string(), storage_name: "state_style".to_string() }],
|
|
1179
|
+
1,
|
|
1180
|
+
)
|
|
1181
|
+
.await
|
|
1182
|
+
.expect("era 1 exists, as Ruby left it");
|
|
1183
|
+
|
|
1184
|
+
let missing = super::head_snapshot("SampleApp", "state_style", 2);
|
|
1185
|
+
assert!(!super::table_exists(&guard, &missing).await.expect("a lookup"), "era 2 starts absent");
|
|
1186
|
+
|
|
1187
|
+
let aggregates =
|
|
1188
|
+
vec![super::Aggregate { name: "StateStyle".to_string(), storage_name: "state_style".to_string() }];
|
|
1189
|
+
super::adopt_head_snapshots(&guard, "SampleApp", &aggregates, 2).await.expect("adopts");
|
|
1190
|
+
|
|
1191
|
+
assert!(super::table_exists(&guard, &missing).await.expect("a lookup"), "era 2 is provisioned now");
|
|
1192
|
+
|
|
1193
|
+
// Idempotent — every boot runs it, the same way Ruby's does.
|
|
1194
|
+
super::adopt_head_snapshots(&guard, "SampleApp", &aggregates, 2).await.expect("adopts again");
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/// The columns match Ruby's, so a table this crate creates is one Ruby can
|
|
1198
|
+
/// still compile a head view over and write a delete tombstone into.
|
|
1199
|
+
#[tokio::test]
|
|
1200
|
+
async fn a_head_snapshot_this_crate_creates_carries_rubys_own_columns() {
|
|
1201
|
+
let guard = own_scratch_db("hecks_host_head_snapshot_columns").await;
|
|
1202
|
+
let aggregates = vec![super::Aggregate { name: "Widget".to_string(), storage_name: "widget".to_string() }];
|
|
1203
|
+
super::adopt_head_snapshots(&guard, "Fixtures", &aggregates, 1).await.expect("adopts");
|
|
1204
|
+
|
|
1205
|
+
let name = super::head_snapshot("Fixtures", "widget", 1);
|
|
1206
|
+
let rows = guard
|
|
1207
|
+
.query(
|
|
1208
|
+
"SELECT column_name, is_nullable, column_default FROM information_schema.columns \
|
|
1209
|
+
WHERE table_name = $1 ORDER BY ordinal_position",
|
|
1210
|
+
&[&name],
|
|
1211
|
+
)
|
|
1212
|
+
.await
|
|
1213
|
+
.expect("columns");
|
|
1214
|
+
let columns: Vec<String> = rows.iter().map(|r| r.get::<_, String>(0)).collect();
|
|
1215
|
+
assert_eq!(columns, vec!["id", "ordinal", "operation", "state"]);
|
|
1216
|
+
|
|
1217
|
+
let operation = rows.iter().find(|r| r.get::<_, String>(0) == "operation").expect("operation");
|
|
1218
|
+
assert_eq!(operation.get::<_, String>(1), "NO", "operation is NOT NULL");
|
|
1219
|
+
assert!(operation.get::<_, Option<String>>(2).unwrap_or_default().contains("save"), "defaults to 'save'");
|
|
1220
|
+
|
|
1221
|
+
let state = rows.iter().find(|r| r.get::<_, String>(0) == "state").expect("state");
|
|
1222
|
+
assert_eq!(state.get::<_, String>(1), "YES", "state is nullable, for a delete tombstone");
|
|
1223
|
+
}
|
|
1224
|
+
use super::*;
|
|
1225
|
+
use crate::journal;
|
|
1226
|
+
use tokio_postgres::NoTls;
|
|
1227
|
+
|
|
1228
|
+
fn rule(name: &str, was: Option<&str>, expression: &str) -> EdgeAggregate {
|
|
1229
|
+
EdgeAggregate { name: name.to_string(), was: was.map(str::to_string), has_compute_or_rekey: false, compiled_state_expression: expression.to_string(), compiled_id_expression: None }
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// A multi-hop chain: era1 "Pizza" -> era2 renamed to "Order" -> era3 an
|
|
1233
|
+
// attribute-only edge on "Order". `names_by_era` must walk this backward
|
|
1234
|
+
// correctly for chain_sql's per-ancestor filter to read the right era.
|
|
1235
|
+
#[test]
|
|
1236
|
+
fn names_by_era_walks_a_multi_hop_rename_chain_backward_correctly() {
|
|
1237
|
+
let edge_1_to_2 = Edge { from: "aaa".to_string(), to: "bbb".to_string(), aggregates: vec![rule("Order", Some("Pizza"), "hecks_tr_rename(state, 'x', 'y')")] };
|
|
1238
|
+
let edge_2_to_3 = Edge { from: "bbb".to_string(), to: "ccc".to_string(), aggregates: vec![rule("Order", None, "hecks_tr_rename(state, 'y', 'z')")] };
|
|
1239
|
+
let edges: Vec<&Edge> = vec![&edge_1_to_2, &edge_2_to_3];
|
|
1240
|
+
|
|
1241
|
+
let (current, storage) = names_by_era("Order", &edges);
|
|
1242
|
+
|
|
1243
|
+
// era 1 (index 0): the old name, "Pizza" -- read from edge_1_to_2's
|
|
1244
|
+
// own `was:`. era 2 (index 1) and era 3 (index 2): "Order",
|
|
1245
|
+
// unchanged since nothing renamed it again.
|
|
1246
|
+
assert_eq!(current, vec!["Pizza".to_string(), "Order".to_string(), "Order".to_string()]);
|
|
1247
|
+
assert_eq!(storage, vec!["pizza".to_string(), "order".to_string(), "order".to_string()]);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
#[test]
|
|
1251
|
+
fn edge_chain_finds_the_real_path_and_refuses_a_broken_one() {
|
|
1252
|
+
let edge = Edge { from: "aaa".to_string(), to: "bbb".to_string(), aggregates: vec![] };
|
|
1253
|
+
let edges = vec![edge.clone()];
|
|
1254
|
+
|
|
1255
|
+
let found = edge_chain(&edges, &["aaa".to_string(), "bbb".to_string()]).expect("a real edge connects aaa to bbb");
|
|
1256
|
+
assert_eq!(found.len(), 1);
|
|
1257
|
+
assert_eq!(found[0].to, "bbb");
|
|
1258
|
+
|
|
1259
|
+
let broken = edge_chain(&edges, &["aaa".to_string(), "zzz".to_string()]);
|
|
1260
|
+
assert!(broken.is_err(), "no edge leads aaa to zzz -- must refuse, not silently skip");
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
fn held(ordinal: i32, label: Option<&str>) -> journal::HeldEra {
|
|
1264
|
+
journal::HeldEra { ordinal, hash: label.map(|_| "irrelevant".to_string()), label: label.map(str::to_string), held_text: String::new(), watermark: None }
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// The four branches `main.rs`'s boot gate calls into — pure, so each
|
|
1268
|
+
// one is provable without a real Postgres.
|
|
1269
|
+
#[test]
|
|
1270
|
+
fn decide_boot_action_covers_all_four_branches() {
|
|
1271
|
+
// No held eras at all -- brand new domain, hold era 1.
|
|
1272
|
+
assert_eq!(decide_boot_action(&[], "abc123"), BootDecision::HoldFirst);
|
|
1273
|
+
|
|
1274
|
+
// A held era already names this exact shape -- boot at it,
|
|
1275
|
+
// even if it's not the latest one (an operator could roll back
|
|
1276
|
+
// to an older binary whose shape a prior era already covers).
|
|
1277
|
+
let already_named = vec![held(1, Some("aaa111")), held(2, Some("bbb222"))];
|
|
1278
|
+
assert_eq!(decide_boot_action(&already_named, "aaa111"), BootDecision::UseExisting { ordinal: 1 });
|
|
1279
|
+
assert_eq!(decide_boot_action(&already_named, "bbb222"), BootDecision::UseExisting { ordinal: 2 });
|
|
1280
|
+
|
|
1281
|
+
// Held, but the latest doesn't match this binary's shape -- mint
|
|
1282
|
+
// the next ordinal from the latest's own label.
|
|
1283
|
+
let drifted = vec![held(1, Some("aaa111"))];
|
|
1284
|
+
assert_eq!(
|
|
1285
|
+
decide_boot_action(&drifted, "ccc333"),
|
|
1286
|
+
BootDecision::Mint { ordinal: 2, from_ordinal: 1, from_label: "aaa111".to_string() }
|
|
1287
|
+
);
|
|
1288
|
+
|
|
1289
|
+
// The latest held era has no label yet (Ruby minted it, never
|
|
1290
|
+
// named it) -- refuse rather than guess whether it matches.
|
|
1291
|
+
let unnamed = vec![held(1, Some("aaa111")), held(2, None)];
|
|
1292
|
+
assert_eq!(decide_boot_action(&unnamed, "ccc333"), BootDecision::LatestUnnamed { ordinal: 2 });
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// Proves Rust needs no Ruby to mint at all: real Postgres, zero Ruby
|
|
1296
|
+
// process — `hold_first` for era 1, a real write, `mint_era` for era 2
|
|
1297
|
+
// via a hand-built rename edge, then a read proving the translation ran.
|
|
1298
|
+
#[tokio::test]
|
|
1299
|
+
async fn rust_mints_both_eras_itself_writes_and_reads_back_the_translated_data() {
|
|
1300
|
+
let db = "rust_host_self_mint_test";
|
|
1301
|
+
let owner = "rust_host_self_mint_owner";
|
|
1302
|
+
|
|
1303
|
+
let admin = tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls).await;
|
|
1304
|
+
let (admin_client, admin_connection) = admin.expect("connect to postgres as admin");
|
|
1305
|
+
tokio::spawn(async move {
|
|
1306
|
+
let _ = admin_connection.await;
|
|
1307
|
+
});
|
|
1308
|
+
let _ = admin_client.batch_execute(&format!("DROP DATABASE IF EXISTS {db} WITH (FORCE)")).await;
|
|
1309
|
+
admin_client.batch_execute(&format!("CREATE DATABASE {db}")).await.expect("create scratch db");
|
|
1310
|
+
let _ = admin_client.batch_execute(&format!("DROP ROLE IF EXISTS {owner}")).await;
|
|
1311
|
+
admin_client.batch_execute(&format!("CREATE ROLE {owner} {}", crate::test_pg::login_clause())).await.expect("create owner role");
|
|
1312
|
+
admin_client.batch_execute(&format!("GRANT CONNECT ON DATABASE {db} TO {owner}")).await.expect("grant connect");
|
|
1313
|
+
|
|
1314
|
+
let grant = tokio_postgres::connect(&crate::test_pg::conninfo(&db), NoTls).await;
|
|
1315
|
+
let (grant_client, grant_connection) = grant.expect("connect to scratch db as superuser to grant schema rights");
|
|
1316
|
+
tokio::spawn(async move {
|
|
1317
|
+
let _ = grant_connection.await;
|
|
1318
|
+
});
|
|
1319
|
+
grant_client.batch_execute(&format!("GRANT USAGE, CREATE ON SCHEMA public TO {owner}")).await.expect("grant schema rights");
|
|
1320
|
+
grant_client.batch_execute(&format!("ALTER DATABASE {db} OWNER TO {owner}")).await.expect("make owner the db owner");
|
|
1321
|
+
|
|
1322
|
+
let (client, connection) = tokio_postgres::connect(&crate::test_pg::conninfo_as(&db, &owner), NoTls).await.expect("connect as owner");
|
|
1323
|
+
tokio::spawn(async move {
|
|
1324
|
+
let _ = connection.await;
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
let domain = "SelfMint";
|
|
1328
|
+
|
|
1329
|
+
fn shape(attribute_name: &str) -> Value {
|
|
1330
|
+
serde_json::json!({
|
|
1331
|
+
"name": "SelfMint",
|
|
1332
|
+
"aggregates": [{
|
|
1333
|
+
"name": "Widget",
|
|
1334
|
+
"identified_by": ["kind"],
|
|
1335
|
+
"attributes": [
|
|
1336
|
+
{"name": attribute_name, "type": "Money", "list": false},
|
|
1337
|
+
{"name": "kind", "type": "Kind", "list": false}
|
|
1338
|
+
],
|
|
1339
|
+
"value_objects": [
|
|
1340
|
+
{"name": "Money", "attributes": [{"name": "cents", "type": "Integer", "list": false}]},
|
|
1341
|
+
{"name": "Kind", "attributes": [{"name": "label", "type": "String", "list": false}]}
|
|
1342
|
+
],
|
|
1343
|
+
"entities": []
|
|
1344
|
+
}]
|
|
1345
|
+
})
|
|
1346
|
+
}
|
|
1347
|
+
let v1_ir = shape("cost");
|
|
1348
|
+
let v2_ir = shape("amount");
|
|
1349
|
+
|
|
1350
|
+
// era 1: Rust mints it, no Ruby, no edge needed.
|
|
1351
|
+
let aggregate = Aggregate { name: "Widget".to_string(), storage_name: "widget".to_string() };
|
|
1352
|
+
hold_first(&client, domain, "v1 source text (opaque to this crate)", &v1_ir, &[aggregate.clone()], None).await.expect("hold_first");
|
|
1353
|
+
|
|
1354
|
+
let config = journal::LineageConfig { domain: domain.to_string(), era: Some(1), mirrored: None };
|
|
1355
|
+
journal::append_lineage_mutation(
|
|
1356
|
+
&client,
|
|
1357
|
+
&config,
|
|
1358
|
+
&journal::Mutation { aggregate: "Widget", id: "w1", operation: "save", state: &serde_json::json!({"cost": {"cents": 100}, "kind": {"label": "w1"}}) },
|
|
1359
|
+
)
|
|
1360
|
+
.await
|
|
1361
|
+
.expect("write w1 under era 1");
|
|
1362
|
+
journal::append_lineage_mutation(
|
|
1363
|
+
&client,
|
|
1364
|
+
&config,
|
|
1365
|
+
&journal::Mutation { aggregate: "Widget", id: "w2", operation: "save", state: &serde_json::json!({"cost": {"cents": 200}, "kind": {"label": "w2"}}) },
|
|
1366
|
+
)
|
|
1367
|
+
.await
|
|
1368
|
+
.expect("write w2 under era 1");
|
|
1369
|
+
|
|
1370
|
+
// era 2: Rust mints it too, via a hand-built rename edge, constructed
|
|
1371
|
+
// directly here since this test proves the mint mechanics, not the
|
|
1372
|
+
// build-time export pipeline (storage_shape.rs's own job).
|
|
1373
|
+
let from_label = storage_shape::mint_label(&v1_ir);
|
|
1374
|
+
let to_label = storage_shape::mint_label(&v2_ir);
|
|
1375
|
+
let edge = Edge {
|
|
1376
|
+
from: from_label,
|
|
1377
|
+
to: to_label.clone(),
|
|
1378
|
+
aggregates: vec![EdgeAggregate {
|
|
1379
|
+
name: "Widget".to_string(),
|
|
1380
|
+
was: None,
|
|
1381
|
+
has_compute_or_rekey: false,
|
|
1382
|
+
compiled_state_expression: "hecks_tr_rename(state, 'cost', 'amount')".to_string(),
|
|
1383
|
+
compiled_id_expression: None,
|
|
1384
|
+
}],
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
mint_era(&client, domain, 2, &storage_shape::mint_hash(&v2_ir), &to_label, "v2 source text", &[aggregate], &[&edge], None, &[])
|
|
1388
|
+
.await
|
|
1389
|
+
.expect("mint_era");
|
|
1390
|
+
|
|
1391
|
+
// Read back through the same generic function real deployment traffic
|
|
1392
|
+
// uses, proving the whole chain, not just that SQL ran.
|
|
1393
|
+
let mut rows = journal::read_lineage_head_all(&client, domain, "widget").await.expect("read_lineage_head_all");
|
|
1394
|
+
rows.sort_by(|a, b| a.0.cmp(&b.0));
|
|
1395
|
+
assert_eq!(
|
|
1396
|
+
rows,
|
|
1397
|
+
vec![
|
|
1398
|
+
("w1".to_string(), serde_json::json!({"amount": {"cents": 100}, "kind": {"label": "w1"}})),
|
|
1399
|
+
("w2".to_string(), serde_json::json!({"amount": {"cents": 200}, "kind": {"label": "w2"}})),
|
|
1400
|
+
]
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
fn registration_default() -> LifecycleDefault {
|
|
1405
|
+
LifecycleDefault { key_prefix: "Studio::Registration#".to_string(), field: "status".to_string(), default: "active".to_string() }
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// A seed as the kernel's `instances()` dump writes it: one entry per
|
|
1409
|
+
// instance under "Domain::Aggregate#id", value objects as nested objects,
|
|
1410
|
+
// a lifecycle field as a plain string.
|
|
1411
|
+
fn old_seed() -> Value {
|
|
1412
|
+
serde_json::json!({
|
|
1413
|
+
"Studio::Event#yoga": {
|
|
1414
|
+
"slug": {"value": "yoga"}, "name": {"value": "Yoga"}, "price": {"cents": 100},
|
|
1415
|
+
"capacity": {"value": 10}, "status": "open"
|
|
1416
|
+
},
|
|
1417
|
+
"Studio::Registration#r1": {
|
|
1418
|
+
"event_slug": "yoga", "registration_id": {"value": "r1"},
|
|
1419
|
+
"attendee": {"name": "Ada", "email": "ada@example.com"}
|
|
1420
|
+
},
|
|
1421
|
+
"Studio::Registration#r2": {
|
|
1422
|
+
"event_slug": "yoga", "registration_id": {"value": "r2"},
|
|
1423
|
+
"attendee": {"name": "Grace", "email": "grace@example.com"}
|
|
1424
|
+
},
|
|
1425
|
+
"Payments::Payment#r1": {"status": "succeeded"}
|
|
1426
|
+
})
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
#[test]
|
|
1430
|
+
fn a_missing_lifecycle_field_gets_the_default() {
|
|
1431
|
+
let mut seed = old_seed();
|
|
1432
|
+
assert_eq!(fill_lifecycle_defaults(&mut seed, &[registration_default()]), 2);
|
|
1433
|
+
assert_eq!(seed["Studio::Registration#r1"]["status"], "active");
|
|
1434
|
+
assert_eq!(seed["Studio::Registration#r2"]["status"], "active");
|
|
1435
|
+
assert_eq!(seed["Studio::Registration#r1"]["attendee"]["email"], "ada@example.com", "everything else on the instance is kept");
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
#[test]
|
|
1439
|
+
fn a_null_lifecycle_field_is_filled_too() {
|
|
1440
|
+
let mut seed = serde_json::json!({"Studio::Registration#r1": {"status": null}});
|
|
1441
|
+
assert_eq!(fill_lifecycle_defaults(&mut seed, &[registration_default()]), 1);
|
|
1442
|
+
assert_eq!(seed["Studio::Registration#r1"]["status"], "active");
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
#[test]
|
|
1446
|
+
fn an_existing_lifecycle_value_is_left_alone() {
|
|
1447
|
+
let mut seed = old_seed();
|
|
1448
|
+
seed["Studio::Registration#r2"]["status"] = serde_json::json!("archived");
|
|
1449
|
+
assert_eq!(fill_lifecycle_defaults(&mut seed, &[registration_default()]), 1);
|
|
1450
|
+
assert_eq!(seed["Studio::Registration#r2"]["status"], "archived");
|
|
1451
|
+
assert_eq!(seed["Studio::Registration#r1"]["status"], "active");
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
#[test]
|
|
1455
|
+
fn aggregates_without_a_declared_default_are_untouched() {
|
|
1456
|
+
let mut seed = old_seed();
|
|
1457
|
+
let before = seed.clone();
|
|
1458
|
+
assert_eq!(fill_lifecycle_defaults(&mut seed, &[]), 0);
|
|
1459
|
+
assert_eq!(seed, before);
|
|
1460
|
+
|
|
1461
|
+
// Only Registration is named: the Event, the Payment, and an aggregate
|
|
1462
|
+
// whose name merely starts with "Registration" stay as they were.
|
|
1463
|
+
let mut seed = old_seed();
|
|
1464
|
+
seed["Studio::RegistrationNote#n1"] = serde_json::json!({"text": "hi"});
|
|
1465
|
+
let mut expected = seed.clone();
|
|
1466
|
+
expected["Studio::Registration#r1"]["status"] = serde_json::json!("active");
|
|
1467
|
+
expected["Studio::Registration#r2"]["status"] = serde_json::json!("active");
|
|
1468
|
+
fill_lifecycle_defaults(&mut seed, &[registration_default()]);
|
|
1469
|
+
assert_eq!(seed, expected);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
#[test]
|
|
1473
|
+
fn filling_twice_changes_nothing_the_second_time() {
|
|
1474
|
+
let mut seed = old_seed();
|
|
1475
|
+
fill_lifecycle_defaults(&mut seed, &[registration_default()]);
|
|
1476
|
+
let once = seed.clone();
|
|
1477
|
+
assert_eq!(fill_lifecycle_defaults(&mut seed, &[registration_default()]), 0);
|
|
1478
|
+
assert_eq!(seed, once);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
#[test]
|
|
1482
|
+
fn a_seed_that_is_not_an_object_or_holds_a_non_object_instance_is_left_alone() {
|
|
1483
|
+
let mut empty = serde_json::json!({});
|
|
1484
|
+
assert_eq!(fill_lifecycle_defaults(&mut empty, &[registration_default()]), 0);
|
|
1485
|
+
let mut not_a_seed = serde_json::json!([1, 2]);
|
|
1486
|
+
assert_eq!(fill_lifecycle_defaults(&mut not_a_seed, &[registration_default()]), 0);
|
|
1487
|
+
let mut odd = serde_json::json!({"Studio::Registration#r1": "scalar"});
|
|
1488
|
+
assert_eq!(fill_lifecycle_defaults(&mut odd, &[registration_default()]), 0);
|
|
1489
|
+
assert_eq!(odd["Studio::Registration#r1"], "scalar");
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
#[test]
|
|
1493
|
+
fn lifecycle_defaults_are_read_from_the_irs_aggregates() {
|
|
1494
|
+
let ir = serde_json::json!({
|
|
1495
|
+
"name": "Studio",
|
|
1496
|
+
"aggregates": [
|
|
1497
|
+
{"name": "Event", "lifecycle": {"field": "status", "default": "open", "transitions": []}},
|
|
1498
|
+
{"name": "Registration", "lifecycle": {"field": "status", "default": "active", "transitions": []}},
|
|
1499
|
+
{"name": "Note", "lifecycle": null},
|
|
1500
|
+
{"name": "Bare"}
|
|
1501
|
+
]
|
|
1502
|
+
});
|
|
1503
|
+
assert_eq!(
|
|
1504
|
+
lifecycle_defaults(&ir),
|
|
1505
|
+
vec![
|
|
1506
|
+
LifecycleDefault { key_prefix: "Studio::Event#".to_string(), field: "status".to_string(), default: "open".to_string() },
|
|
1507
|
+
registration_default(),
|
|
1508
|
+
]
|
|
1509
|
+
);
|
|
1510
|
+
assert!(lifecycle_defaults(&serde_json::json!({"name": "Studio"})).is_empty());
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/// The snapshot a live host wrote under the old shape holds registrations
|
|
1514
|
+
/// with no `status`; minting the new era fills them inside the mint's own transaction.
|
|
1515
|
+
#[tokio::test]
|
|
1516
|
+
async fn minting_an_era_fills_lifecycle_defaults_into_the_stored_snapshot() {
|
|
1517
|
+
let client = own_scratch_db("hecks_host_mint_snapshot_fill").await;
|
|
1518
|
+
journal::ensure_schema(&client).await.expect("ensure_schema");
|
|
1519
|
+
ensure_base(&client, "Studio").await.expect("ensure_base");
|
|
1520
|
+
|
|
1521
|
+
fn shape(with_status: bool) -> Value {
|
|
1522
|
+
let mut aggregate = serde_json::json!({
|
|
1523
|
+
"name": "Registration",
|
|
1524
|
+
"identified_by": ["registration_id"],
|
|
1525
|
+
"attributes": [{"name": "registration_id", "type": "Id", "list": false}],
|
|
1526
|
+
"value_objects": [{"name": "Id", "attributes": [{"name": "value", "type": "String", "list": false}]}],
|
|
1527
|
+
"entities": []
|
|
1528
|
+
});
|
|
1529
|
+
if with_status {
|
|
1530
|
+
aggregate["lifecycle"] = serde_json::json!({"field": "status", "default": "active", "transitions": []});
|
|
1531
|
+
}
|
|
1532
|
+
serde_json::json!({"name": "Studio", "aggregates": [aggregate]})
|
|
1533
|
+
}
|
|
1534
|
+
let (v1_ir, v2_ir) = (shape(false), shape(true));
|
|
1535
|
+
let aggregate = Aggregate { name: "Registration".to_string(), storage_name: "registration".to_string() };
|
|
1536
|
+
hold_first(&client, "Studio", "v1 source", &v1_ir, &[aggregate.clone()], None).await.expect("hold_first");
|
|
1537
|
+
journal::save_snapshot(&client, 7, &old_seed()).await.expect("save_snapshot");
|
|
1538
|
+
|
|
1539
|
+
let to_label = storage_shape::mint_label(&v2_ir);
|
|
1540
|
+
let edge = Edge {
|
|
1541
|
+
from: storage_shape::mint_label(&v1_ir),
|
|
1542
|
+
to: to_label.clone(),
|
|
1543
|
+
aggregates: vec![EdgeAggregate {
|
|
1544
|
+
name: "Registration".to_string(),
|
|
1545
|
+
was: None,
|
|
1546
|
+
has_compute_or_rekey: false,
|
|
1547
|
+
compiled_state_expression: "state || jsonb_build_object('status', 'active')".to_string(),
|
|
1548
|
+
compiled_id_expression: None,
|
|
1549
|
+
}],
|
|
1550
|
+
};
|
|
1551
|
+
let defaults = lifecycle_defaults(&v2_ir);
|
|
1552
|
+
assert_eq!(defaults.len(), 1);
|
|
1553
|
+
// The defaults name "Studio::Registration#", matching the seed's keys.
|
|
1554
|
+
mint_era(&client, "Studio", 2, &storage_shape::mint_hash(&v2_ir), &to_label, "v2 source", &[aggregate], &[&edge], None, &defaults)
|
|
1555
|
+
.await
|
|
1556
|
+
.expect("mint_era");
|
|
1557
|
+
|
|
1558
|
+
let snapshot = journal::load_snapshot(&client).await.unwrap().expect("the snapshot row is still there");
|
|
1559
|
+
assert_eq!(snapshot.ordinal, 7, "the snapshot's ordinal is not touched, only its instances");
|
|
1560
|
+
assert_eq!(snapshot.seed["Studio::Registration#r1"]["status"], "active");
|
|
1561
|
+
assert_eq!(snapshot.seed["Studio::Registration#r2"]["status"], "active");
|
|
1562
|
+
assert!(snapshot.seed["Studio::Event#yoga"]["status"] == "open" && snapshot.seed["Payments::Payment#r1"]["status"] == "succeeded");
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
#[tokio::test]
|
|
1566
|
+
async fn filling_a_database_with_no_snapshot_table_or_row_is_a_no_op() {
|
|
1567
|
+
let client = own_scratch_db("hecks_host_mint_snapshot_fill_absent").await;
|
|
1568
|
+
assert_eq!(fill_snapshot_lifecycle_defaults(&client, "Studio", &[registration_default()]).await.unwrap(), 0, "no table");
|
|
1569
|
+
journal::ensure_schema(&client).await.expect("ensure_schema");
|
|
1570
|
+
assert_eq!(fill_snapshot_lifecycle_defaults(&client, "Studio", &[registration_default()]).await.unwrap(), 0, "no row");
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
/// The compiled expression a backfill rule writes for one path, wrapped around `inner`.
|
|
1574
|
+
fn backfill_expression(inner: &str, path: &[&str], default_json: &str) -> String {
|
|
1575
|
+
let array = format!("ARRAY[{}]::text[]", path.iter().map(|p| format!("'{p}'")).collect::<Vec<_>>().join(", "));
|
|
1576
|
+
let label = path.join(".");
|
|
1577
|
+
format!(
|
|
1578
|
+
"(SELECT CASE WHEN (hecks_tr_extract(__s, {array})).present THEN __s ELSE hecks_tr_insert(__s, {array}, '{default_json}'::jsonb, 'backfill {label}') END FROM (SELECT ({inner}) AS __s) __outer)"
|
|
1579
|
+
)
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/// Every row a chain statement yields, as sorted text.
|
|
1583
|
+
async fn raw_rows(client: &tokio_postgres::Client, sql: &str) -> Vec<String> {
|
|
1584
|
+
let mut rows: Vec<String> = client
|
|
1585
|
+
.query(&format!("SELECT ordinal::text || '|' || aggregate_id || '|' || operation || '|' || coalesce(state::text, 'null') FROM ({sql}) c"), &[])
|
|
1586
|
+
.await
|
|
1587
|
+
.expect("chain query")
|
|
1588
|
+
.iter()
|
|
1589
|
+
.map(|r| r.get::<_, String>(0))
|
|
1590
|
+
.collect();
|
|
1591
|
+
rows.sort();
|
|
1592
|
+
rows
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
fn edge_of(from: &str, to: &str, aggregate: &str, expression: &str) -> Edge {
|
|
1596
|
+
Edge {
|
|
1597
|
+
from: from.to_string(),
|
|
1598
|
+
to: to.to_string(),
|
|
1599
|
+
aggregates: vec![EdgeAggregate {
|
|
1600
|
+
name: aggregate.to_string(),
|
|
1601
|
+
was: None,
|
|
1602
|
+
has_compute_or_rekey: false,
|
|
1603
|
+
compiled_state_expression: expression.to_string(),
|
|
1604
|
+
compiled_id_expression: None,
|
|
1605
|
+
}],
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/// The statement text carries the fence on the tail and on every edge, for any
|
|
1610
|
+
/// chain length: that is what keeps the planner from copying an edge's
|
|
1611
|
+
/// expression into each read of `state`.
|
|
1612
|
+
#[test]
|
|
1613
|
+
fn every_cte_of_the_chain_is_materialized() {
|
|
1614
|
+
let watermarks = std::collections::HashMap::new();
|
|
1615
|
+
let edges: Vec<Edge> = (1..=6).map(|n| edge_of(&format!("e{n}"), &format!("e{}", n + 1), "Registration", "state")).collect();
|
|
1616
|
+
for n in 1..=6usize {
|
|
1617
|
+
let chain: Vec<&Edge> = edges[..n].iter().collect();
|
|
1618
|
+
let sql = chain_sql("Chain", "Registration", (n + 1) as i32, &chain, &watermarks);
|
|
1619
|
+
assert!(sql.starts_with("WITH tail AS MATERIALIZED ("), "{sql}");
|
|
1620
|
+
assert_eq!(sql.matches("AS MATERIALIZED (").count(), n + 1, "the tail and each of the {n} edges: {sql}");
|
|
1621
|
+
for edge in 1..=n {
|
|
1622
|
+
assert!(sql.contains(&format!("edge_{edge} AS MATERIALIZED (")), "edge {edge}: {sql}");
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/// The plan stays linear in the chain length. Counting the plan's lines is
|
|
1628
|
+
/// deterministic (a timing assertion would flake): a fenced 12-edge chain
|
|
1629
|
+
/// stays small, and an unfenced 5-edge chain is already many times its size.
|
|
1630
|
+
#[tokio::test]
|
|
1631
|
+
async fn the_chain_plan_grows_with_the_edges_not_exponentially() {
|
|
1632
|
+
let client = own_scratch_db("hecks_host_mint_chain_plan_size").await;
|
|
1633
|
+
journal::ensure_schema(&client).await.expect("ensure_schema");
|
|
1634
|
+
let aggregates = vec![Aggregate { name: "Registration".to_string(), storage_name: "registration".to_string() }];
|
|
1635
|
+
hold_first(&client, "Plan", "era 1", &serde_json::json!({"name": "Plan", "aggregates": []}), &aggregates, None).await.expect("hold_first");
|
|
1636
|
+
|
|
1637
|
+
let mut heavy = "hecks_tr_drop(state, ARRAY['attendee', 'name']::text[])".to_string();
|
|
1638
|
+
for field in ["first_name", "last_name", "phone", "how_heard", "aim"] {
|
|
1639
|
+
heavy = backfill_expression(&heavy, &["attendee", field], "\"(none)\"");
|
|
1640
|
+
}
|
|
1641
|
+
let mut edges = vec![edge_of("e1", "e2", "Registration", &heavy)];
|
|
1642
|
+
for n in 2..=11 {
|
|
1643
|
+
edges.push(edge_of(&format!("e{n}"), &format!("e{}", n + 1), "Registration", "state"));
|
|
1644
|
+
}
|
|
1645
|
+
edges.push(edge_of("e12", "e13", "Registration", &backfill_expression("state", &["status"], "\"active\"")));
|
|
1646
|
+
|
|
1647
|
+
let watermarks = std::collections::HashMap::new();
|
|
1648
|
+
let plan_lines = |chain: &[&Edge], materialize: bool| {
|
|
1649
|
+
let sql = chain_sql_with("Plan", "Registration", (chain.len() + 1) as i32, chain, &watermarks, materialize);
|
|
1650
|
+
let client = &client;
|
|
1651
|
+
async move { client.query(&format!("EXPLAIN (COSTS OFF) {sql}"), &[]).await.expect("explain").len() }
|
|
1652
|
+
};
|
|
1653
|
+
|
|
1654
|
+
let all: Vec<&Edge> = edges.iter().collect();
|
|
1655
|
+
let fenced_12 = plan_lines(&all, true).await;
|
|
1656
|
+
let fenced_6 = plan_lines(&all[..6], true).await;
|
|
1657
|
+
assert!(fenced_12 < 2 * fenced_6.max(500), "12 fenced edges plan to {fenced_12} lines, 6 to {fenced_6}");
|
|
1658
|
+
let inlined_5 = plan_lines(&all[..5], false).await;
|
|
1659
|
+
let fenced_5 = plan_lines(&all[..5], true).await;
|
|
1660
|
+
assert!(inlined_5 > 10 * fenced_5, "unfenced 5 edges: {inlined_5} plan lines; fenced: {fenced_5}");
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
/// Materializing each edge's CTE must not change one row of any chain: five
|
|
1664
|
+
/// eras of history, a delete and a re-saved id, compared as raw rows and as
|
|
1665
|
+
/// the latest-per-id result the audit and the head build read.
|
|
1666
|
+
#[tokio::test]
|
|
1667
|
+
async fn materializing_the_edge_ctes_returns_exactly_the_rows_the_inlined_chain_does() {
|
|
1668
|
+
let client = own_scratch_db("hecks_host_mint_chain_differential").await;
|
|
1669
|
+
journal::ensure_schema(&client).await.expect("ensure_schema");
|
|
1670
|
+
let domain = "Chain";
|
|
1671
|
+
let aggregates = vec![
|
|
1672
|
+
Aggregate { name: "Registration".to_string(), storage_name: "registration".to_string() },
|
|
1673
|
+
Aggregate { name: "Event".to_string(), storage_name: "event".to_string() },
|
|
1674
|
+
];
|
|
1675
|
+
let ir = serde_json::json!({"name": domain, "aggregates": []});
|
|
1676
|
+
hold_first(&client, domain, "era 1", &ir, &aggregates, None).await.expect("hold_first");
|
|
1677
|
+
|
|
1678
|
+
// Edge 1 backfills four nested paths; edges 2 and 3 carry no rule (as
|
|
1679
|
+
// most of a real deployment's edges do); edge 4 backfills one more.
|
|
1680
|
+
let mut nested = "hecks_tr_drop(state, ARRAY['attendee', 'name']::text[])".to_string();
|
|
1681
|
+
for field in ["first_name", "last_name", "phone", "aim"] {
|
|
1682
|
+
nested = backfill_expression(&nested, &["attendee", field], "\"(none)\"");
|
|
1683
|
+
}
|
|
1684
|
+
let edges = vec![
|
|
1685
|
+
edge_of("e1", "e2", "Registration", &nested),
|
|
1686
|
+
edge_of("e2", "e3", "Event", "state"),
|
|
1687
|
+
edge_of("e3", "e4", "Event", "state"),
|
|
1688
|
+
edge_of("e4", "e5", "Registration", &backfill_expression("state", &["status"], "\"active\"")),
|
|
1689
|
+
];
|
|
1690
|
+
|
|
1691
|
+
let journal_name = "hecks_journal_chain";
|
|
1692
|
+
let insert = |era: i32, aggregate: &str, id: &str, operation: &str, state: Value| {
|
|
1693
|
+
let client = &client;
|
|
1694
|
+
let (aggregate, id, operation) = (aggregate.to_string(), id.to_string(), operation.to_string());
|
|
1695
|
+
async move {
|
|
1696
|
+
client
|
|
1697
|
+
.execute(
|
|
1698
|
+
&format!("INSERT INTO {journal_name} (era, aggregate, aggregate_id, operation, state) VALUES ($1, $2, $3, $4, $5)"),
|
|
1699
|
+
&[&era, &aggregate, &id, &operation, &state],
|
|
1700
|
+
)
|
|
1701
|
+
.await
|
|
1702
|
+
.expect("journal insert");
|
|
1703
|
+
}
|
|
1704
|
+
};
|
|
1705
|
+
for era in 1..=5i32 {
|
|
1706
|
+
// era 1 carries the old attendee shape, later eras the new one
|
|
1707
|
+
let attendee = |n: &str| if era == 1 { serde_json::json!({"name": n, "email": "a@example.com"}) } else { serde_json::json!({"first_name": n, "email": "a@example.com"}) };
|
|
1708
|
+
for i in 0..6 {
|
|
1709
|
+
let id = format!("r{}", (era as usize + i) % 5);
|
|
1710
|
+
insert(era, "registration", &id, "save", serde_json::json!({"attendee": attendee(&format!("n{era}{i}"))})).await;
|
|
1711
|
+
}
|
|
1712
|
+
insert(era, "event", &format!("e{era}"), "save", serde_json::json!({"name": {"value": format!("event {era}")}})).await;
|
|
1713
|
+
insert(era, "event", "e1", "save", serde_json::json!({"name": {"value": "renamed"}})).await;
|
|
1714
|
+
if era == 3 {
|
|
1715
|
+
insert(era, "registration", "r0", "delete", Value::Null).await;
|
|
1716
|
+
}
|
|
1717
|
+
if era < 5 {
|
|
1718
|
+
let chain: Vec<&Edge> = edges[..era as usize].iter().collect();
|
|
1719
|
+
mint_era(&client, domain, era + 1, &"h".repeat(64), &format!("e{}", era + 1), "text", &aggregates, &chain, None, &[]).await.expect("mint_era");
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
let held = journal::held_eras(&client, domain).await.unwrap();
|
|
1724
|
+
let watermarks: std::collections::HashMap<i32, Option<i64>> = held.iter().map(|h| (h.ordinal, h.watermark)).collect();
|
|
1725
|
+
let mut compared = 0;
|
|
1726
|
+
for aggregate in &aggregates {
|
|
1727
|
+
for n in 1..=edges.len() {
|
|
1728
|
+
let chain: Vec<&Edge> = edges[..n].iter().collect();
|
|
1729
|
+
let era = (n + 1) as i32;
|
|
1730
|
+
let inlined = chain_sql_with(domain, &aggregate.name, era, &chain, &watermarks, false);
|
|
1731
|
+
let fenced = chain_sql_with(domain, &aggregate.name, era, &chain, &watermarks, true);
|
|
1732
|
+
assert_ne!(inlined, fenced, "the two forms really differ");
|
|
1733
|
+
assert!(fenced.contains("AS MATERIALIZED (") && !inlined.contains("MATERIALIZED"));
|
|
1734
|
+
|
|
1735
|
+
let (a, b) = (raw_rows(&client, &inlined).await, raw_rows(&client, &fenced).await);
|
|
1736
|
+
assert!(!a.is_empty(), "{} chain of {n}: the fixture yields rows", aggregate.name);
|
|
1737
|
+
assert_eq!(a, b, "{} chain of {n}: raw rows", aggregate.name);
|
|
1738
|
+
assert_eq!(
|
|
1739
|
+
latest_of(&client, &inlined).await.unwrap(),
|
|
1740
|
+
latest_of(&client, &fenced).await.unwrap(),
|
|
1741
|
+
"{} chain of {n}: latest per id",
|
|
1742
|
+
aggregate.name
|
|
1743
|
+
);
|
|
1744
|
+
compared += 1;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
assert_eq!(compared, 8);
|
|
1748
|
+
// and the fixture exercised what it claims to: a backfilled status reached the head
|
|
1749
|
+
let translated = translated_latest(&client, domain, "Registration", 5, &edges.iter().collect::<Vec<_>>(), &watermarks).await.unwrap();
|
|
1750
|
+
assert!(translated.values().all(|state| state["status"] == "active"), "{translated:?}");
|
|
1751
|
+
assert_eq!(translated.len(), 5, "five registrations were ever saved: {translated:?}");
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// Layer 2, proven live both directions: an edge whose declared rules agree
|
|
1755
|
+
// with the compiled SQL passes silently; one that disagrees (the exact bug
|
|
1756
|
+
// this audit exists to catch) refuses, naming the divergence.
|
|
1757
|
+
#[tokio::test]
|
|
1758
|
+
async fn audit_before_mint_passes_a_true_edge_and_refuses_a_lying_one() {
|
|
1759
|
+
let db = "rust_host_audit_test";
|
|
1760
|
+
let owner = "rust_host_audit_owner";
|
|
1761
|
+
|
|
1762
|
+
let admin = tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls).await;
|
|
1763
|
+
let (admin_client, admin_connection) = admin.expect("connect to postgres as admin");
|
|
1764
|
+
tokio::spawn(async move {
|
|
1765
|
+
let _ = admin_connection.await;
|
|
1766
|
+
});
|
|
1767
|
+
let _ = admin_client.batch_execute(&format!("DROP DATABASE IF EXISTS {db} WITH (FORCE)")).await;
|
|
1768
|
+
admin_client.batch_execute(&format!("CREATE DATABASE {db}")).await.expect("create scratch db");
|
|
1769
|
+
let _ = admin_client.batch_execute(&format!("DROP ROLE IF EXISTS {owner}")).await;
|
|
1770
|
+
admin_client.batch_execute(&format!("CREATE ROLE {owner} {}", crate::test_pg::login_clause())).await.expect("create owner role");
|
|
1771
|
+
admin_client.batch_execute(&format!("GRANT CONNECT ON DATABASE {db} TO {owner}")).await.expect("grant connect");
|
|
1772
|
+
|
|
1773
|
+
let grant = tokio_postgres::connect(&crate::test_pg::conninfo(&db), NoTls).await;
|
|
1774
|
+
let (grant_client, grant_connection) = grant.expect("connect to scratch db as superuser to grant schema rights");
|
|
1775
|
+
tokio::spawn(async move {
|
|
1776
|
+
let _ = grant_connection.await;
|
|
1777
|
+
});
|
|
1778
|
+
grant_client.batch_execute(&format!("GRANT USAGE, CREATE ON SCHEMA public TO {owner}")).await.expect("grant schema rights");
|
|
1779
|
+
grant_client.batch_execute(&format!("ALTER DATABASE {db} OWNER TO {owner}")).await.expect("make owner the db owner");
|
|
1780
|
+
|
|
1781
|
+
let (client, connection) = tokio_postgres::connect(&crate::test_pg::conninfo_as(&db, &owner), NoTls).await.expect("connect as owner");
|
|
1782
|
+
tokio::spawn(async move {
|
|
1783
|
+
let _ = connection.await;
|
|
1784
|
+
});
|
|
1785
|
+
|
|
1786
|
+
fn shape(attribute_name: &str) -> Value {
|
|
1787
|
+
serde_json::json!({
|
|
1788
|
+
"name": "AuditTest",
|
|
1789
|
+
"aggregates": [{
|
|
1790
|
+
"name": "Widget",
|
|
1791
|
+
"identified_by": ["kind"],
|
|
1792
|
+
"attributes": [
|
|
1793
|
+
{"name": attribute_name, "type": "Money", "list": false},
|
|
1794
|
+
{"name": "kind", "type": "Kind", "list": false}
|
|
1795
|
+
],
|
|
1796
|
+
"value_objects": [
|
|
1797
|
+
{"name": "Money", "attributes": [{"name": "cents", "type": "Integer", "list": false}]},
|
|
1798
|
+
{"name": "Kind", "attributes": [{"name": "label", "type": "String", "list": false}]}
|
|
1799
|
+
],
|
|
1800
|
+
"entities": []
|
|
1801
|
+
}]
|
|
1802
|
+
})
|
|
1803
|
+
}
|
|
1804
|
+
let v1_ir = shape("cost");
|
|
1805
|
+
let v2_ir = shape("amount");
|
|
1806
|
+
let aggregate = Aggregate { name: "Widget".to_string(), storage_name: "widget".to_string() };
|
|
1807
|
+
let from_label = storage_shape::mint_label(&v1_ir);
|
|
1808
|
+
let to_label = storage_shape::mint_label(&v2_ir);
|
|
1809
|
+
|
|
1810
|
+
async fn seed_era_one(client: &tokio_postgres::Client, domain: &str, v1_ir: &Value, aggregate: &Aggregate) {
|
|
1811
|
+
hold_first(client, domain, "v1 source text", v1_ir, std::slice::from_ref(aggregate), None).await.expect("hold_first");
|
|
1812
|
+
let config = journal::LineageConfig { domain: domain.to_string(), era: Some(1), mirrored: None };
|
|
1813
|
+
journal::append_lineage_mutation(
|
|
1814
|
+
client,
|
|
1815
|
+
&config,
|
|
1816
|
+
&journal::Mutation { aggregate: "Widget", id: "w1", operation: "save", state: &serde_json::json!({"cost": {"cents": 100}, "kind": {"label": "w1"}}) },
|
|
1817
|
+
)
|
|
1818
|
+
.await
|
|
1819
|
+
.expect("write w1 under era 1");
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// The honest edge: declared renames genuinely match the compiled SQL —
|
|
1823
|
+
// audit_before_mint must find nothing wrong.
|
|
1824
|
+
let honest_domain = "AuditPass";
|
|
1825
|
+
seed_era_one(&client, honest_domain, &v1_ir, &aggregate).await;
|
|
1826
|
+
let honest_edge = Edge {
|
|
1827
|
+
from: from_label.clone(),
|
|
1828
|
+
to: to_label.clone(),
|
|
1829
|
+
aggregates: vec![EdgeAggregate {
|
|
1830
|
+
name: "Widget".to_string(),
|
|
1831
|
+
was: None,
|
|
1832
|
+
has_compute_or_rekey: false,
|
|
1833
|
+
compiled_state_expression: "hecks_tr_rename(state, 'cost', 'amount')".to_string(),
|
|
1834
|
+
compiled_id_expression: None,
|
|
1835
|
+
}],
|
|
1836
|
+
};
|
|
1837
|
+
let honest_raw_edges = vec![serde_json::json!({
|
|
1838
|
+
"domain": honest_domain, "from": from_label, "to": to_label,
|
|
1839
|
+
"aggregates": [{"name": "Widget", "renames": {"cost": "amount"}}]
|
|
1840
|
+
})];
|
|
1841
|
+
let watermarks: std::collections::HashMap<i32, Option<i64>> = [(1, None)].into_iter().collect();
|
|
1842
|
+
audit_before_mint(&client, honest_domain, &v2_ir, &[aggregate.clone()], 2, &[&honest_edge], &honest_raw_edges, &watermarks)
|
|
1843
|
+
.await
|
|
1844
|
+
.expect("an honest edge's audit must pass silently");
|
|
1845
|
+
|
|
1846
|
+
// The lying edge: declared `renames:` claims a different destination
|
|
1847
|
+
// than the compiled SQL produces — the exact bug this audit exists to
|
|
1848
|
+
// catch. Must refuse, naming the divergence, before anything mints.
|
|
1849
|
+
let lying_domain = "AuditFail";
|
|
1850
|
+
seed_era_one(&client, lying_domain, &v1_ir, &aggregate).await;
|
|
1851
|
+
let lying_edge = honest_edge.clone();
|
|
1852
|
+
let lying_raw_edges = vec![serde_json::json!({
|
|
1853
|
+
"domain": lying_domain, "from": from_label, "to": to_label,
|
|
1854
|
+
// Claims `cost` becomes `price` -- the compiled SQL above
|
|
1855
|
+
// still renames it to `amount`. A real divergence.
|
|
1856
|
+
"aggregates": [{"name": "Widget", "renames": {"cost": "price"}}]
|
|
1857
|
+
})];
|
|
1858
|
+
let error = audit_before_mint(&client, lying_domain, &v2_ir, &[aggregate], 2, &[&lying_edge], &lying_raw_edges, &watermarks)
|
|
1859
|
+
.await
|
|
1860
|
+
.expect_err("a declared rename that disagrees with the compiled SQL must refuse");
|
|
1861
|
+
let message = format!("{error:#}");
|
|
1862
|
+
assert!(message.contains("the audit refused"), "{message}");
|
|
1863
|
+
assert!(message.contains("diverges from the reference transform"), "{message}");
|
|
1864
|
+
}
|
|
1865
|
+
}
|