hecks 1.2.0 → 1.4.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/lib/hecks/adapters/driven/claude_code.rb +7 -7
- data/lib/hecks/adapters/driven/d1.rb +187 -23
- data/lib/hecks/adapters/driven/folder.rb +10 -10
- data/lib/hecks/adapters/driven/google_authentication.rb +8 -8
- data/lib/hecks/adapters/driven/governance_authorization.rb +31 -13
- data/lib/hecks/adapters/driven/heki/journal.rb +60 -2
- data/lib/hecks/adapters/driven/heki/saga_store.rb +5 -5
- data/lib/hecks/adapters/driven/heki.rb +13 -7
- data/lib/hecks/adapters/driven/identity_registry.rb +2 -2
- data/lib/hecks/adapters/driven/in_memory_ordering.rb +3 -3
- data/lib/hecks/adapters/driven/lambda/client.rb +34 -9
- data/lib/hecks/adapters/driven/lambda.rb +39 -33
- data/lib/hecks/adapters/driven/local_storage.rb +17 -10
- data/lib/hecks/adapters/driven/memory.rb +205 -9
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +1 -1
- data/lib/hecks/adapters/driven/postgres/codec.rb +27 -11
- data/lib/hecks/adapters/driven/postgres/outbox.rb +40 -2
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +23 -7
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +14 -14
- data/lib/hecks/adapters/driven/postgres.rb +175 -28
- data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
- data/lib/hecks/adapters/driven/prism.rb +4 -4
- data/lib/hecks/adapters/driven/sql_query_builder.rb +34 -22
- data/lib/hecks/adapters/driven/sqlite/codec.rb +38 -10
- data/lib/hecks/adapters/driven/sqlite/projection.rb +60 -32
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +12 -12
- data/lib/hecks/adapters/driven/sqlite.rb +181 -21
- data/lib/hecks/adapters/driven.rb +4 -4
- data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
- data/lib/hecks/behaviors/dsl.rb +2 -2
- data/lib/hecks/behaviors/expectations.rb +51 -23
- data/lib/hecks/behaviors/ir.rb +1 -1
- data/lib/hecks/behaviors/rspec.rb +1 -1
- data/lib/hecks/behaviors/runner.rb +2 -2
- data/lib/hecks/behaviors.rb +1 -1
- data/lib/hecks/bluebook/aggregate.rb +13 -13
- data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +10 -10
- data/lib/hecks/bluebook/assembly/build.rb +1 -1
- data/lib/hecks/bluebook/assembly/contract.rb +39 -16
- data/lib/hecks/bluebook/assembly/contracts.rb +40 -38
- data/lib/hecks/bluebook/assembly/marks.rb +18 -18
- data/lib/hecks/bluebook/assembly/specializer.rb +19 -21
- data/lib/hecks/bluebook/assembly.rb +9 -9
- data/lib/hecks/bluebook/attribute.rb +9 -9
- data/lib/hecks/bluebook/behaviour/aggregate.rb +11 -11
- data/lib/hecks/bluebook/behaviour/attribute.rb +5 -5
- data/lib/hecks/bluebook/behaviour/chapter.rb +23 -5
- data/lib/hecks/bluebook/behaviour/command.rb +23 -23
- data/lib/hecks/bluebook/behaviour/domain_port.rb +27 -3
- data/lib/hecks/bluebook/behaviour/entity.rb +8 -8
- data/lib/hecks/bluebook/behaviour/hexagon.rb +4 -4
- data/lib/hecks/bluebook/behaviour/lifecycle.rb +5 -5
- data/lib/hecks/bluebook/behaviour/policy.rb +12 -12
- data/lib/hecks/bluebook/behaviour/process_manager.rb +7 -7
- data/lib/hecks/bluebook/behaviour/query.rb +1 -1
- data/lib/hecks/bluebook/behaviour/read_model.rb +8 -8
- data/lib/hecks/bluebook/behaviour/traits.rb +12 -12
- data/lib/hecks/bluebook/behaviour/value_object.rb +6 -6
- data/lib/hecks/bluebook/capabilities.rb +27 -0
- data/lib/hecks/bluebook/chapter.rb +28 -9
- data/lib/hecks/bluebook/command.rb +12 -12
- data/lib/hecks/bluebook/domain_port.rb +9 -9
- data/lib/hecks/bluebook/dsl/adapter_builder.rb +24 -0
- data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +49 -49
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +96 -96
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +41 -41
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +22 -2
- data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +111 -74
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +48 -30
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +116 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +103 -103
- data/lib/hecks/bluebook/dsl/const_shim.rb +46 -15
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +90 -25
- data/lib/hecks/bluebook/dsl/entity_builder.rb +56 -56
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +148 -132
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +89 -30
- data/lib/hecks/bluebook/dsl/identity_declaration.rb +17 -17
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +27 -4
- data/lib/hecks/bluebook/dsl/policy_builder.rb +30 -21
- data/lib/hecks/bluebook/dsl/port_builder.rb +38 -7
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +56 -22
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +35 -35
- data/lib/hecks/bluebook/dsl/query_builder.rb +5 -5
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +34 -34
- data/lib/hecks/bluebook/dsl/rule_reference.rb +41 -39
- data/lib/hecks/bluebook/dsl/translation_builder.rb +9 -9
- data/lib/hecks/bluebook/dsl/value_object_builder.rb +16 -16
- data/lib/hecks/bluebook/dsl/word_gate.rb +59 -53
- data/lib/hecks/bluebook/dsl/world_builder.rb +51 -8
- data/lib/hecks/bluebook/entity.rb +11 -11
- data/lib/hecks/bluebook/expression/ast_json.rb +20 -20
- data/lib/hecks/bluebook/expression/ast_reader.rb +3 -3
- data/lib/hecks/bluebook/expression/canonical_form.rb +9 -9
- data/lib/hecks/bluebook/expression/evaluator.rb +18 -18
- data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +18 -18
- data/lib/hecks/bluebook/expression/resolver.rb +60 -62
- data/lib/hecks/bluebook/hexagon.rb +1 -1
- data/lib/hecks/bluebook/lifecycle.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/judge.rb +122 -86
- data/lib/hecks/bluebook/meta_validator/plan.rb +39 -39
- data/lib/hecks/bluebook/meta_validator/port_judge.rb +2 -2
- data/lib/hecks/bluebook/meta_validator/readings.rb +43 -43
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +44 -37
- data/lib/hecks/bluebook/meta_validator/shapes.rb +25 -21
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +145 -31
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +6 -6
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +5 -5
- data/lib/hecks/bluebook/meta_validator.rb +70 -70
- data/lib/hecks/bluebook/model_check.rb +301 -84
- data/lib/hecks/bluebook/pattern_subset.rb +9 -9
- data/lib/hecks/bluebook/policy.rb +15 -13
- data/lib/hecks/bluebook/process_manager.rb +14 -14
- data/lib/hecks/bluebook/project_register.rb +6 -6
- data/lib/hecks/bluebook/query.rb +4 -4
- data/lib/hecks/bluebook/read_model.rb +14 -14
- data/lib/hecks/bluebook/reference.rb +8 -8
- data/lib/hecks/bluebook/smoke_test.rb +19 -19
- data/lib/hecks/bluebook/synthesizer.rb +12 -12
- data/lib/hecks/bluebook/translation.rb +4 -4
- data/lib/hecks/bluebook/value_object.rb +6 -6
- data/lib/hecks/bluebook.rb +2 -2
- data/lib/hecks/codemod/legacy_dispatch_args.rb +299 -0
- data/lib/hecks/codemod/legacy_dispatch_recorder.rb +186 -0
- data/lib/hecks/codemod.rb +36 -35
- data/lib/hecks/construct.rb +6 -6
- data/lib/hecks/corpus.rb +317 -0
- data/lib/hecks/deprecation.rb +95 -0
- data/lib/hecks/doc/reference.rb +19 -19
- data/lib/hecks/embryonaut_bluebook.rb +11 -11
- data/lib/hecks/facade/cli_door.rb +69 -10
- data/lib/hecks/facade/cli_runner.rb +105 -24
- data/lib/hecks/facade/command_request.rb +23 -0
- data/lib/hecks/facade/handle.rb +79 -32
- data/lib/hecks/facade/json_door.rb +106 -25
- data/lib/hecks/facade/surface/aggregate_door.rb +42 -27
- data/lib/hecks/facade/surface/chapter.rb +26 -17
- data/lib/hecks/facade/surface.rb +16 -3
- data/lib/hecks/facade.rb +15 -4
- data/lib/hecks/forms/app.rb +46 -30
- data/lib/hecks/forms/command_form_renderer.rb +70 -9
- data/lib/hecks/forms/field_renderer.rb +142 -6
- data/lib/hecks/forms/field_shape.rb +183 -20
- data/lib/hecks/forms/html.rb +51 -7
- data/lib/hecks/forms/index_renderer.rb +14 -2
- data/lib/hecks/forms/params.rb +120 -23
- data/lib/hecks/forms/port_argument.rb +2 -2
- data/lib/hecks/forms/query_form_renderer.rb +2 -2
- data/lib/hecks/forms/record_renderer.rb +2 -2
- data/lib/hecks/forms/record_table.rb +1 -1
- data/lib/hecks/forms/value_object_shape.rb +3 -3
- data/lib/hecks/forms.rb +24 -4
- data/lib/hecks/fqn.rb +1 -1
- data/lib/hecks/framework/bluebook/governance.bluebook +9 -0
- data/lib/hecks/framework.rb +48 -17
- data/lib/hecks/freezer.rb +11 -11
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +90 -90
- data/lib/hecks/fuzzing/combination_miner.rb +119 -0
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +361 -0
- data/lib/hecks/fuzzing/coverage_campaign.rb +118 -0
- data/lib/hecks/fuzzing/differential.rb +158 -0
- data/lib/hecks/fuzzing/domain_generator.rb +694 -0
- data/lib/hecks/fuzzing/era_boundary.rb +124 -0
- data/lib/hecks/fuzzing/form_census.rb +199 -0
- data/lib/hecks/fuzzing/generated_domain_check.rb +95 -0
- data/lib/hecks/fuzzing/invalid_value_generator.rb +6 -6
- data/lib/hecks/fuzzing/isolated_boot.rb +226 -38
- data/lib/hecks/fuzzing/nondeterministic.rb +67 -0
- data/lib/hecks/fuzzing/persistence_parity.rb +161 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +321 -41
- data/lib/hecks/fuzzing/properties/guards.rb +129 -26
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +32 -32
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +31 -11
- data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
- data/lib/hecks/fuzzing/properties/querying.rb +17 -17
- data/lib/hecks/fuzzing/properties.rb +84 -39
- data/lib/hecks/fuzzing/qa_settings.rb +152 -0
- data/lib/hecks/fuzzing/replay.rb +224 -114
- data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
- data/lib/hecks/fuzzing/rust_gap_manifest.rb +113 -0
- data/lib/hecks/fuzzing/self_consistency.rb +676 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +101 -30
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +55 -8
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +31 -11
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +123 -32
- data/lib/hecks/fuzzing/sequence_generator.rb +152 -31
- data/lib/hecks/fuzzing/shrinker.rb +197 -0
- data/lib/hecks/fuzzing/structural_skips.rb +39 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +180 -0
- data/lib/hecks/fuzzing/value_generator.rb +66 -14
- data/lib/hecks/fuzzing.rb +11 -0
- data/lib/hecks/grammar/evolve.rb +10 -10
- data/lib/hecks/grammar.rb +7 -7
- data/lib/hecks/ir.rb +13 -13
- data/lib/hecks/language/bluebook/bluebook.bluebook +41 -0
- data/lib/hecks/language/bluebook/policy.bluebook +11 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +365 -15
- data/lib/hecks/language/oidc.json +5 -0
- data/lib/hecks/literal.rb +9 -9
- data/lib/hecks/naming.rb +89 -21
- data/lib/hecks/ports/access_control.rb +58 -2
- data/lib/hecks/ports/agent/answers.rb +83 -6
- data/lib/hecks/ports/agent.rb +119 -35
- data/lib/hecks/ports/authentication.rb +44 -4
- data/lib/hecks/ports/authorization.rb +53 -11
- data/lib/hecks/ports/clock.rb +42 -23
- data/lib/hecks/ports/extraction.rb +16 -0
- data/lib/hecks/ports/identity_assignment.rb +24 -2
- data/lib/hecks/ports/identity_generation.rb +17 -3
- data/lib/hecks/ports/identity_resolution.rb +18 -1
- data/lib/hecks/ports/loading.rb +4 -0
- data/lib/hecks/ports/persistence/append_only.rb +172 -8
- data/lib/hecks/ports/persistence/binding_policy.rb +34 -0
- data/lib/hecks/ports/persistence/codec_boundary.rb +178 -0
- data/lib/hecks/ports/persistence/execution.rb +4 -0
- data/lib/hecks/ports/persistence/null_saga_store.rb +12 -1
- data/lib/hecks/ports/persistence/plugin.rb +42 -4
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +218 -25
- data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +77 -9
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +81 -24
- data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +29 -18
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +144 -60
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +103 -8
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +98 -23
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +282 -109
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +63 -25
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +160 -58
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +51 -28
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +28 -5
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +25 -12
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +129 -34
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +51 -6
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +44 -6
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +16 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +57 -4
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +25 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +338 -83
- data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +68 -10
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +9 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +9 -2
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +42 -8
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +16 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +36 -5
- data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +23 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +18 -19
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +5 -5
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era.rb +12 -2
- data/lib/hecks/ports/persistence/remote_runtime.rb +9 -2
- data/lib/hecks/ports/persistence/repository_factory.rb +29 -6
- data/lib/hecks/ports/persistence/state_codec.rb +319 -0
- data/lib/hecks/ports/persistence.rb +36 -1
- data/lib/hecks/ports/projection.rb +61 -7
- data/lib/hecks/ports/query/in_memory.rb +3 -3
- data/lib/hecks/ports/query/ordering.rb +6 -6
- data/lib/hecks/ports/query.rb +35 -0
- data/lib/hecks/projections/bootstrap_table.rb +112 -0
- data/lib/hecks/projections/diagrams.rb +75 -75
- data/lib/hecks/projections/glossary/html.rb +250 -0
- data/lib/hecks/projections/glossary/markdown.rb +105 -0
- data/lib/hecks/projections/glossary/mermaid.rb +110 -0
- data/lib/hecks/projections/glossary/page.css +271 -0
- data/lib/hecks/projections/glossary/page.js +72 -0
- data/lib/hecks/projections/glossary/sections.rb +17 -0
- data/lib/hecks/projections/glossary/sentences.rb +205 -0
- data/lib/hecks/projections/glossary.rb +214 -286
- data/lib/hecks/projections/ir.rb +1 -1
- data/lib/hecks/projections/model/deviations.rb +18 -17
- data/lib/hecks/projections/model.rb +25 -21
- data/lib/hecks/projections/oidc.rb +7 -7
- data/lib/hecks/projections/parser_table.rb +5 -5
- data/lib/hecks/projections/reference.rb +3 -3
- data/lib/hecks/projections/rust_vocabulary.rb +443 -0
- data/lib/hecks/projections/shape.rb +2 -2
- data/lib/hecks/projections/statements.rb +11 -11
- data/lib/hecks/projections/vocabulary.rb +9 -9
- data/lib/hecks/projections.rb +5 -3
- data/lib/hecks/projector/cli_projector.rb +29 -29
- data/lib/hecks/projector/docs_projector.rb +13 -13
- data/lib/hecks/projector/exporter.rb +42 -21
- data/lib/hecks/projector/ir_projector.rb +1 -1
- data/lib/hecks/projector/narrate_projector.rb +15 -22
- data/lib/hecks/projector/target.rb +13 -13
- data/lib/hecks/projector.rb +15 -15
- data/lib/hecks/query_ir.rb +47 -47
- data/lib/hecks/query_specification/common/comparators.rb +19 -3
- data/lib/hecks/query_specification/common/comparison.rb +132 -24
- data/lib/hecks/query_specification/common/dsl.rb +65 -9
- data/lib/hecks/query_specification/common/null_policy.rb +57 -13
- data/lib/hecks/query_specification/common/null_semantics.rb +4 -0
- data/lib/hecks/query_specification/common/options.rb +25 -0
- data/lib/hecks/query_specification/field_path.rb +69 -15
- data/lib/hecks/query_specification/hop_path.rb +57 -20
- data/lib/hecks/query_specification/read_model/specification.rb +4 -0
- data/lib/hecks/rendering.rb +3 -3
- data/lib/hecks/router/namespace_installer.rb +3 -3
- data/lib/hecks/router.rb +1 -1
- data/lib/hecks/runtime/aggregate_lock.rb +11 -11
- data/lib/hecks/runtime/boot_gates.rb +3 -3
- data/lib/hecks/runtime/caller.rb +8 -8
- data/lib/hecks/runtime/capability_graph.rb +2 -2
- data/lib/hecks/runtime/command_interpreter/argument_gate.rb +22 -25
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +64 -67
- data/lib/hecks/runtime/command_interpreter.rb +159 -102
- data/lib/hecks/runtime/command_rules/admissibility.rb +69 -69
- data/lib/hecks/runtime/command_rules/arithmetic.rb +137 -91
- data/lib/hecks/runtime/command_rules/authorization.rb +38 -17
- data/lib/hecks/runtime/command_rules/emission.rb +18 -1
- data/lib/hecks/runtime/command_rules/references.rb +94 -19
- data/lib/hecks/runtime/command_rules.rb +3 -0
- data/lib/hecks/runtime/dependency_planning.rb +11 -11
- data/lib/hecks/runtime/dispatcher.rb +283 -90
- data/lib/hecks/runtime/entity_element.rb +282 -46
- data/lib/hecks/runtime/entity_interpreter.rb +160 -69
- data/lib/hecks/runtime/errors.rb +19 -19
- data/lib/hecks/runtime/event.rb +6 -6
- data/lib/hecks/runtime/identity.rb +22 -22
- data/lib/hecks/runtime/instance.rb +39 -14
- data/lib/hecks/runtime/interpreting.rb +12 -12
- data/lib/hecks/runtime/invocation.rb +276 -0
- data/lib/hecks/runtime/loader.rb +14 -14
- data/lib/hecks/runtime/outbox.rb +23 -23
- data/lib/hecks/runtime/policy_interpreter.rb +54 -54
- data/lib/hecks/runtime/port_operation_interpreter.rb +22 -19
- data/lib/hecks/runtime/query_interpreter.rb +111 -56
- data/lib/hecks/runtime/reaction_invocation.rb +76 -9
- data/lib/hecks/runtime/read_model_interpreter.rb +40 -40
- data/lib/hecks/runtime/rebuild_sweep.rb +4 -4
- data/lib/hecks/runtime/reference_hop.rb +6 -6
- data/lib/hecks/runtime/refusal_wording.rb +92 -112
- data/lib/hecks/runtime/registry/saga_persistence.rb +21 -21
- data/lib/hecks/runtime/registry/verification.rb +36 -26
- data/lib/hecks/runtime/registry.rb +56 -27
- data/lib/hecks/runtime/remote_dispatcher.rb +38 -23
- data/lib/hecks/runtime/routing.rb +10 -88
- data/lib/hecks/runtime/saga_interpreter/correlation.rb +17 -17
- data/lib/hecks/runtime/saga_interpreter.rb +93 -56
- data/lib/hecks/runtime/saga_pending_dispatch.rb +12 -12
- data/lib/hecks/runtime/tenant_check.rb +9 -9
- data/lib/hecks/runtime/tenant_scope.rb +5 -5
- data/lib/hecks/runtime/value/admission.rb +75 -30
- data/lib/hecks/runtime/value/coercion.rb +379 -226
- data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
- data/lib/hecks/runtime/value.rb +28 -23
- data/lib/hecks/runtime.rb +7 -7
- data/lib/hecks/storehouse.rb +64 -64
- data/lib/hecks/version.rb +3 -3
- data/lib/hecks/vocabulary.rb +207 -5
- data/lib/hecks.rb +13 -11
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +11 -11
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +14 -12
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +11 -11
- metadata +43 -2
|
@@ -14,7 +14,11 @@ module Hecks
|
|
|
14
14
|
module HeadCompiler
|
|
15
15
|
# ── head compilation ───────────────────────────────────────────
|
|
16
16
|
|
|
17
|
-
#
|
|
17
|
+
# Creates one aggregate's head-snapshot table for an era if it is missing,
|
|
18
|
+
# brings an older table up to the current column shape, and backfills it
|
|
19
|
+
# from the journal until the backfill is recorded complete.
|
|
20
|
+
#
|
|
21
|
+
# The snapshot table backing one aggregate's current era: one row
|
|
18
22
|
# per live id, upserted transactionally by PostgresEra#append
|
|
19
23
|
# alongside the journal insert it belongs to, never derived by
|
|
20
24
|
# scanning history thereafter. Idempotent and unguarded by
|
|
@@ -22,40 +26,45 @@ module Hecks
|
|
|
22
26
|
# privilege class as the per-aggregate views/matviews already
|
|
23
27
|
# created this way, not the shared owner-only journal.
|
|
24
28
|
#
|
|
25
|
-
#
|
|
29
|
+
# Backfilled, not just created, the first time this table comes
|
|
26
30
|
# into existence for a domain that already has journal history —
|
|
27
31
|
# a fresh era (compile_head! at mint) never has any, but era 1
|
|
28
|
-
# against an
|
|
32
|
+
# against an existing deployment (any domain running before this
|
|
29
33
|
# snapshot table existed at all) does, and an empty table would
|
|
30
34
|
# silently erase every already-written record from every read
|
|
31
35
|
# the instant the head view starts pointing at it.
|
|
32
36
|
#
|
|
33
|
-
#
|
|
37
|
+
# Creation and backfill are deliberately two separate steps,
|
|
34
38
|
# not one nested transaction — principle 1 (docs/implemented/postgres-era-
|
|
35
39
|
# adapter-split-plan.md): no operation may hold a lock across a
|
|
36
40
|
# scan whose duration scales with table size, and
|
|
37
|
-
# `backfill_head_snapshot!` below is
|
|
41
|
+
# `backfill_head_snapshot!` below is a chunked, resumable
|
|
38
42
|
# scan (see `ResumableBackfill`), the opposite of something that
|
|
39
43
|
# belongs inside one transaction. Creation alone (an empty
|
|
40
|
-
# table, nothing to scan) still needs
|
|
41
|
-
#
|
|
44
|
+
# table, nothing to scan) still needs its short-held lock.
|
|
45
|
+
# Backfill runs unconditionally after —
|
|
42
46
|
# cheap and correct whether the table was just created, already
|
|
43
47
|
# fully backfilled by a prior boot (one indexed point lookup via
|
|
44
48
|
# `hecks_backfill_progress`, see ResumableBackfill), or left
|
|
45
49
|
# mid-backfill by a crashed or still-racing concurrent boot
|
|
46
|
-
# (picks up exactly where the last
|
|
50
|
+
# (picks up exactly where the last committed chunk left off).
|
|
51
|
+
#
|
|
52
|
+
# @param storage_name [String] the aggregate's snake-cased storage name in `era`
|
|
53
|
+
# @param era [Integer] ordinal of the era the snapshot belongs to
|
|
54
|
+
# @return [void]
|
|
55
|
+
# @raise [PG::Error] if Postgres refuses the DDL, a journal read, or an upsert
|
|
47
56
|
def ensure_head_snapshot!(storage_name, era)
|
|
48
57
|
name = head_snapshot(storage_name, era)
|
|
49
58
|
unless table_exists?(name)
|
|
50
59
|
# Locked, not a bare CREATE TABLE IF NOT EXISTS — two
|
|
51
60
|
# processes booting this aggregate for the very first time
|
|
52
|
-
# concurrently must not race to CREATE (one wins, one gets a
|
|
61
|
+
# concurrently must not race to `CREATE` (one wins, one gets a
|
|
53
62
|
# real Postgres error). Re-checked under the lock: the fast
|
|
54
63
|
# path above skips locking entirely once any boot has
|
|
55
64
|
# already finished this once, which is every boot after the
|
|
56
65
|
# first.
|
|
57
66
|
#
|
|
58
|
-
#
|
|
67
|
+
# Nestable — this runs both standalone (adapter boot, its
|
|
59
68
|
# own transaction) and from `compile_head!` while
|
|
60
69
|
# `mint_era!` is already mid-transaction (its manual
|
|
61
70
|
# `BEGIN`, held open for the era row, every aggregate's
|
|
@@ -63,7 +72,7 @@ module Hecks
|
|
|
63
72
|
# `@db.transaction` is a bare BEGIN/COMMIT with no savepoint
|
|
64
73
|
# nesting (see H2 in docs/audits/2026-08-10-main-bug-
|
|
65
74
|
# audit.md) — called while already inside a transaction, its
|
|
66
|
-
# COMMIT would end
|
|
75
|
+
# COMMIT would end that transaction early, releasing mint's
|
|
67
76
|
# advisory lock and letting a later step run uncommitted.
|
|
68
77
|
# `nested_transaction` tells the two cases apart and uses a
|
|
69
78
|
# SAVEPOINT for the second, so the surrounding mint stays
|
|
@@ -73,22 +82,21 @@ module Hecks
|
|
|
73
82
|
@db.exec_params("SELECT pg_advisory_xact_lock(hashtext('hecks_head_snapshot:' || $1))", [name])
|
|
74
83
|
next if table_exists?(name)
|
|
75
84
|
|
|
76
|
-
# `operation` + a
|
|
77
|
-
# 10-main-bug-audit.md: a delete
|
|
78
|
-
# this table
|
|
79
|
-
# from an ancestor era. `compile_head!`'s union below
|
|
80
|
-
#
|
|
85
|
+
# `operation` + a nullable `state` — H3, docs/audits/2026-08-
|
|
86
|
+
# 10-main-bug-audit.md: a delete that just ran `DELETE FROM`
|
|
87
|
+
# this table would leave no row at all for an id carried in
|
|
88
|
+
# from an ancestor era. `compile_head!`'s union below would
|
|
89
|
+
# then have nothing current-era to outrank the ancestor
|
|
81
90
|
# matview's own (still-live-looking) `save` row with, so a
|
|
82
|
-
# deleted ancestor-carried record
|
|
83
|
-
# ON` forever. A delete
|
|
91
|
+
# deleted ancestor-carried record would keep winning
|
|
92
|
+
# `DISTINCT ON` forever. A delete upserts a tombstone row here
|
|
84
93
|
# instead (`operation = 'delete'`, `state` NULL) — the
|
|
85
94
|
# exact same ordinal-guarded upsert every write already
|
|
86
95
|
# uses, so it participates in the union/`DISTINCT ON`
|
|
87
96
|
# exactly like a save does, and out-ranks the ancestor row
|
|
88
|
-
# by ordinal the same way a real re-save
|
|
97
|
+
# by ordinal the same way a real re-save does
|
|
89
98
|
# ("re-saves are masked correctly" — the audit's own
|
|
90
|
-
# phrasing for why
|
|
91
|
-
# broken).
|
|
99
|
+
# phrasing for why that half of the read path holds).
|
|
92
100
|
@db.exec(<<~SQL)
|
|
93
101
|
CREATE TABLE #{quote(name)} (
|
|
94
102
|
id text PRIMARY KEY,
|
|
@@ -99,7 +107,7 @@ module Hecks
|
|
|
99
107
|
SQL
|
|
100
108
|
end
|
|
101
109
|
end
|
|
102
|
-
#
|
|
110
|
+
# Self-healing for a table this ADR predates — same idiom as
|
|
103
111
|
# `postgres/schema_builder.rb`'s own `hecks_version` backfill:
|
|
104
112
|
# unconditional, runs on every boot, a no-op once the column
|
|
105
113
|
# is there. A table created before H3's fix has no
|
|
@@ -111,8 +119,12 @@ module Hecks
|
|
|
111
119
|
backfill_head_snapshot!(name, storage_name, era)
|
|
112
120
|
end
|
|
113
121
|
|
|
114
|
-
#
|
|
115
|
-
#
|
|
122
|
+
# Fills a head-snapshot table with the latest saved state of every live id
|
|
123
|
+
# in one era's journal, one committed chunk at a time, resuming from the
|
|
124
|
+
# cursor in `hecks_backfill_progress` and doing nothing once complete.
|
|
125
|
+
#
|
|
126
|
+
# The reduction a live view over the raw journal would run on every
|
|
127
|
+
# single read — DISTINCT ON latest-per-id, saves only — run once,
|
|
116
128
|
# chunked through `ResumableBackfill#chunked_backfill!` instead
|
|
117
129
|
# of one blocking `INSERT ... SELECT` over the whole journal:
|
|
118
130
|
# each chunk reads the next page of distinct ids (a plain SELECT,
|
|
@@ -120,7 +132,13 @@ module Hecks
|
|
|
120
132
|
# by this running), then upserts it under the same short-held
|
|
121
133
|
# advisory lock + ordinal guard every other upsert in this
|
|
122
134
|
# adapter already uses. See `ResumableBackfill`'s own header for
|
|
123
|
-
# why this is
|
|
135
|
+
# why this is resumable, not merely safe-to-restart.
|
|
136
|
+
#
|
|
137
|
+
# @param name [String] unquoted snapshot table name, from `head_snapshot`
|
|
138
|
+
# @param storage_name [String] the aggregate's snake-cased storage name in `era`
|
|
139
|
+
# @param era [Integer] ordinal of the era whose journal rows are read
|
|
140
|
+
# @return [void]
|
|
141
|
+
# @raise [PG::Error] if Postgres refuses a journal read or an upsert
|
|
124
142
|
def backfill_head_snapshot!(name, storage_name, era)
|
|
125
143
|
chunked_backfill!(
|
|
126
144
|
name,
|
|
@@ -151,10 +169,19 @@ module Hecks
|
|
|
151
169
|
)
|
|
152
170
|
end
|
|
153
171
|
|
|
172
|
+
# Tells whether a relation of this name resolves on the connection's search
|
|
173
|
+
# path, by `to_regclass` — any relation kind counts, not only a table.
|
|
174
|
+
#
|
|
175
|
+
# @param name [String] unquoted relation name
|
|
176
|
+
# @return [Boolean] true when `to_regclass` resolves the name
|
|
177
|
+
# @raise [PG::Error] if the lookup fails
|
|
154
178
|
def table_exists?(name)
|
|
155
179
|
@db.exec_params("SELECT to_regclass($1) IS NOT NULL AS present", [name]).getvalue(0, 0) == "t"
|
|
156
180
|
end
|
|
157
181
|
|
|
182
|
+
# Runs the block atomically: in its own transaction when the connection is
|
|
183
|
+
# idle, under a savepoint when a transaction is already open.
|
|
184
|
+
#
|
|
158
185
|
# A transaction wrapper safe to call from inside an already-open
|
|
159
186
|
# transaction, unlike `PG::Connection#transaction` (bare
|
|
160
187
|
# BEGIN/COMMIT, no savepoint nesting — see H2). Standalone
|
|
@@ -166,6 +193,16 @@ module Hecks
|
|
|
166
193
|
# re-raised) on failure, and either way the surrounding
|
|
167
194
|
# transaction is never touched — no early COMMIT, no early
|
|
168
195
|
# release of whatever advisory lock it holds.
|
|
196
|
+
#
|
|
197
|
+
# @param name [String] savepoint name for the nested case, interpolated
|
|
198
|
+
# unquoted so it must be a plain SQL identifier; unused when standalone
|
|
199
|
+
# @yield the statements to run atomically
|
|
200
|
+
# @yieldparam connection [PG::Connection] the connection, standalone only;
|
|
201
|
+
# the nested case yields nothing
|
|
202
|
+
# @return [Object] the block's value when standalone, the `PG::Result` of
|
|
203
|
+
# `RELEASE SAVEPOINT` when nested; callers here ignore it
|
|
204
|
+
# @raise [StandardError] whatever the block raises, re-raised after the
|
|
205
|
+
# transaction or the savepoint is rolled back
|
|
169
206
|
def nested_transaction(name, &)
|
|
170
207
|
return @db.transaction(&) if @db.transaction_status == PG::PQTRANS_IDLE
|
|
171
208
|
|
|
@@ -179,13 +216,20 @@ module Hecks
|
|
|
179
216
|
end
|
|
180
217
|
end
|
|
181
218
|
|
|
219
|
+
# Ensures era 1's snapshot table for an aggregate, then points its head
|
|
220
|
+
# view at that table's saved rows, replacing any earlier definition.
|
|
221
|
+
#
|
|
182
222
|
# Era 1: the head is the snapshot table itself, verbatim — no
|
|
183
223
|
# per-read reduction over history left to do, because `append`
|
|
184
224
|
# already keeps the snapshot current as of every write.
|
|
225
|
+
#
|
|
226
|
+
# @param storage_name [String] the aggregate's snake-cased storage name
|
|
227
|
+
# @return [void]
|
|
228
|
+
# @raise [PG::Error] if Postgres refuses the DDL or the snapshot backfill
|
|
185
229
|
def ensure_first_head!(storage_name)
|
|
186
230
|
ensure_head_snapshot!(storage_name, 1)
|
|
187
231
|
# `WHERE operation = 'save'` — era 1 has no ancestor tail to
|
|
188
|
-
# worry about, but the snapshot table can
|
|
232
|
+
# worry about, but the snapshot table can hold delete
|
|
189
233
|
# tombstones too (H3, see `ensure_head_snapshot!`'s own
|
|
190
234
|
# comment), and a tombstone's `state` is NULL: without this
|
|
191
235
|
# filter a deleted id would still resolve here, just to a nil
|
|
@@ -196,19 +240,10 @@ module Hecks
|
|
|
196
240
|
SQL
|
|
197
241
|
end
|
|
198
242
|
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
# era through exactly this SQL before anything is minted).
|
|
243
|
+
# Wraps an ancestor-tail query so it keeps only the newest journal entry
|
|
244
|
+
# per aggregate id, deletes included, before any edge is applied.
|
|
202
245
|
#
|
|
203
|
-
#
|
|
204
|
-
# tempting the optimization looks. The two-line counterexample:
|
|
205
|
-
# edge 1 renames A→B, edge 2 renames C→A. Flattened, a single
|
|
206
|
-
# phase order either applies C→A before A→B (aliasing C's value
|
|
207
|
-
# into B) or drops the recycled name entirely — there is NO
|
|
208
|
-
# correct position for both rules in one pass. Chaining the
|
|
209
|
-
# original edges in mint order reproduces the true execution
|
|
210
|
-
# exactly and needs no such reasoning.
|
|
211
|
-
# ONLY THE LATEST ANCESTOR ENTRY PER ID IS OBSERVABLE. The head,
|
|
246
|
+
# Only the latest ancestor entry per ID is observable. The head,
|
|
212
247
|
# the tail-merge, and the audit all reduce by
|
|
213
248
|
# DISTINCT ON (aggregate_id) ORDER BY ordinal DESC before anyone
|
|
214
249
|
# reads a state, so an entry with a newer sibling can never
|
|
@@ -217,13 +252,18 @@ module Hecks
|
|
|
217
252
|
# edited a hundred times cost a hundred translations to serve
|
|
218
253
|
# one.
|
|
219
254
|
#
|
|
220
|
-
# So the tail is reduced
|
|
255
|
+
# So the tail is reduced before the chain, not after. The
|
|
221
256
|
# translated output is identical (the reducer is idempotent and
|
|
222
257
|
# the survivor is the same row either way); only the work
|
|
223
258
|
# changes, from |journal entries| to |distinct records|.
|
|
224
259
|
#
|
|
225
|
-
# `era` must survive the reduction: each edge's CASE reads it to
|
|
260
|
+
# `era` must survive the reduction: each edge's `CASE` reads it to
|
|
226
261
|
# decide whether a row is old enough to need that edge applied.
|
|
262
|
+
#
|
|
263
|
+
# @param tail [String, nil] the `ancestor_tail_sql` SELECT, or an empty string
|
|
264
|
+
# (or nil) when the aggregate has no ancestor era
|
|
265
|
+
# @return [String, nil] a `SELECT DISTINCT ON (aggregate_id)` over `tail`, or
|
|
266
|
+
# `tail` itself, unchanged, when it is empty or nil
|
|
227
267
|
def latest_per_id(tail)
|
|
228
268
|
return tail if tail.to_s.empty?
|
|
229
269
|
|
|
@@ -231,15 +271,18 @@ module Hecks
|
|
|
231
271
|
"FROM (#{tail}) tail_entries ORDER BY aggregate_id, ordinal DESC"
|
|
232
272
|
end
|
|
233
273
|
|
|
234
|
-
#
|
|
274
|
+
# Builds the ancestor-tail SQL for era N by applying only the last edge to
|
|
275
|
+
# era N-1's existing matview plus era N-1's own journal rows.
|
|
276
|
+
#
|
|
277
|
+
# **The layered build** — era N from era N-1's matview, not from raw
|
|
235
278
|
# history. Returns nil when it cannot apply, and the caller
|
|
236
|
-
# falls back to the full chain
|
|
279
|
+
# falls back to the full chain (`chain_sql`).
|
|
237
280
|
#
|
|
238
281
|
# The algebra it rests on, both halves load-bearing:
|
|
239
282
|
#
|
|
240
|
-
# 1.
|
|
283
|
+
# 1. The cuts do not move. ancestor_tail_sql cuts ancestor k at
|
|
241
284
|
# W(k+1) — the watermark recorded when era k+1 was minted.
|
|
242
|
-
# Those are the
|
|
285
|
+
# Those are the same literals in the era N-1 build and the
|
|
243
286
|
# era N build, so era N-1's matview already carries exactly
|
|
244
287
|
# the cut era N needs for eras 1..N-2. Only era N-1's own
|
|
245
288
|
# rows are new, and they are cut at W(N). The watermark is
|
|
@@ -247,7 +290,7 @@ module Hecks
|
|
|
247
290
|
# baked into the layer beneath. (This is why the tail-merge
|
|
248
291
|
# must pass full: — it moves every watermark at once.)
|
|
249
292
|
#
|
|
250
|
-
# 2.
|
|
293
|
+
# 2. Reducing is associative. reduce(A ∪ B) == reduce(reduce(A) ∪ B),
|
|
251
294
|
# because the survivor is max-ordinal-per-id either way. So
|
|
252
295
|
# reducing per layer is the same answer as reducing the
|
|
253
296
|
# whole tail once.
|
|
@@ -256,22 +299,23 @@ module Hecks
|
|
|
256
299
|
# N-1's shape — the matview because it was chained through edge
|
|
257
300
|
# N-2, era N-1's own rows because that is the shape they were
|
|
258
301
|
# written under — so the final edge applies uniformly, with no
|
|
259
|
-
# per-era CASE
|
|
302
|
+
# per-era `CASE`. That equality is asserted, not argued: the spec
|
|
260
303
|
# builds a third era both ways and diffs them.
|
|
261
|
-
#
|
|
262
|
-
#
|
|
304
|
+
#
|
|
305
|
+
# `edges.size != era - 1` is a guard in its own right, not a
|
|
306
|
+
# restatement of the two before it: `names_by_era(aggregate,
|
|
263
307
|
# edges)` sizes `names[:storage]` to `edges.size + 1`, and the
|
|
264
|
-
# union
|
|
265
|
-
# only when `edges` is the
|
|
308
|
+
# union in the body indexes it at `names[:storage][era - 2]` — correct
|
|
309
|
+
# only when `edges` is the full chain reaching `era` (mint's own
|
|
266
310
|
# call, and the audit's own "after" reading, both are). A caller
|
|
267
|
-
# handed a
|
|
311
|
+
# handed a shorter chain against the same `era` — exactly what
|
|
268
312
|
# the audit's own "before" reading is, `chain[0..-2]` against the
|
|
269
313
|
# unchanged target era — would index `names[:storage]` out of
|
|
270
314
|
# its real bounds instead of falling back to `chain_sql` the way
|
|
271
315
|
# every other mismatch here already does. Guarded structurally,
|
|
272
316
|
# not by trusting every future caller to know this invariant.
|
|
273
317
|
#
|
|
274
|
-
# The guard clauses
|
|
318
|
+
# The guard clauses are the method: each rules out one way
|
|
275
319
|
# the layered shortcut cannot honestly apply (era too young, too
|
|
276
320
|
# few edges, a mismatched chain length, no prior era, no label,
|
|
277
321
|
# no materialized prior view) before the SQL-building tail runs.
|
|
@@ -279,6 +323,16 @@ module Hecks
|
|
|
279
323
|
# comment it protects, and thread `held`/`prior`/`prior_view`
|
|
280
324
|
# back out as return values with no simpler shape than they
|
|
281
325
|
# already have.
|
|
326
|
+
#
|
|
327
|
+
# @param aggregate [Bluebook::Aggregate] the aggregate as the target era declares it
|
|
328
|
+
# @param era [Integer] ordinal of the era being built
|
|
329
|
+
# @param edges [Array<Hash{Symbol => Bluebook::Translation}>] one
|
|
330
|
+
# `{ translation: }` per edge, in mint order; only the last is applied
|
|
331
|
+
# @return [String, nil] the layered SELECT, or nil when `era` is under 3, `edges`
|
|
332
|
+
# is not the full chain of `era - 1` edges, or era N-1 has no row, no label, or
|
|
333
|
+
# no existing matview
|
|
334
|
+
# @raise [Runtime::WiringError] if a held era's text fails its digest check
|
|
335
|
+
# @raise [PG::Error] if reading `hecks_eras` or the catalog fails
|
|
282
336
|
# rubocop:disable-next Metrics/CyclomaticComplexity
|
|
283
337
|
# rubocop:disable-next Metrics/PerceivedComplexity
|
|
284
338
|
def layered_chain_sql(aggregate, era, edges)
|
|
@@ -317,6 +371,32 @@ module Hecks
|
|
|
317
371
|
SQL
|
|
318
372
|
end
|
|
319
373
|
|
|
374
|
+
# Builds the full translation query for an aggregate: the reduced ancestor
|
|
375
|
+
# tail as a CTE, then one CTE per edge in mint order, edge k applying its
|
|
376
|
+
# rules only to saved rows written in era k or earlier.
|
|
377
|
+
#
|
|
378
|
+
# The compiled chain over the ancestor tail — the matview's body,
|
|
379
|
+
# also runnable as a live query (the audit previews a pending
|
|
380
|
+
# era through exactly this SQL before anything is minted).
|
|
381
|
+
#
|
|
382
|
+
# Never flatten the edges into one merged rule set, however
|
|
383
|
+
# tempting the optimization looks. The two-line counterexample:
|
|
384
|
+
# edge 1 renames A→B, edge 2 renames C→A. Flattened, a single
|
|
385
|
+
# phase order either applies C→A before A→B (aliasing C's value
|
|
386
|
+
# into B) or drops the recycled name entirely — there is no
|
|
387
|
+
# correct position for both rules in one pass. Chaining the
|
|
388
|
+
# original edges in mint order reproduces the true execution
|
|
389
|
+
# exactly and needs no such reasoning.
|
|
390
|
+
#
|
|
391
|
+
# @param aggregate [Bluebook::Aggregate] the aggregate as the target era declares it
|
|
392
|
+
# @param era [Integer] ordinal of the era being built; ancestors are eras
|
|
393
|
+
# `1...era`
|
|
394
|
+
# @param edges [Array<Hash{Symbol => Bluebook::Translation}>] one
|
|
395
|
+
# `{ translation: }` per edge, in mint order
|
|
396
|
+
# @return [String] a `WITH tail AS (...), edge_1 AS (...), ...` SELECT yielding
|
|
397
|
+
# `ordinal, aggregate_id, operation, state`
|
|
398
|
+
# @raise [Runtime::WiringError] if a held era's text fails its digest check
|
|
399
|
+
# @raise [PG::Error] if reading `hecks_eras` fails
|
|
320
400
|
def chain_sql(aggregate, era, edges)
|
|
321
401
|
names = names_by_era(aggregate, edges)
|
|
322
402
|
tail = latest_per_id(ancestor_tail_sql(names, era))
|
|
@@ -342,25 +422,50 @@ module Hecks
|
|
|
342
422
|
SQL
|
|
343
423
|
end
|
|
344
424
|
|
|
425
|
+
# Runs the head body as a live query and returns every surviving record's
|
|
426
|
+
# translated state, without creating or changing any relation.
|
|
427
|
+
#
|
|
345
428
|
# The translated tail as it would stand in era `era`, latest
|
|
346
429
|
# entry per id, saves only — what the audit holds up against the
|
|
347
|
-
# bluebook and the edge. Reads through `head_body_sql`, the
|
|
430
|
+
# bluebook and the edge. Reads through `head_body_sql`, the same
|
|
348
431
|
# layered-or-full choice `compile_head!` makes at real mint time
|
|
349
432
|
# (that method's own comment has the full reasoning) — so this
|
|
350
433
|
# is provably what the real mint will materialize, not a second
|
|
351
434
|
# implementation that could silently drift from it. Safe for
|
|
352
|
-
#
|
|
435
|
+
# both real callers: the "after" reading (`edges` is the full
|
|
353
436
|
# chain reaching `era`) and the "before" reading (`edges` one
|
|
354
437
|
# shorter, same `era` — `audit.rb`'s own `samples_for`/`check`
|
|
355
438
|
# callers) — `layered_chain_sql`'s own `edges.size != era - 1`
|
|
356
439
|
# guard falls back to `chain_sql` exactly when the shorter
|
|
357
440
|
# chain can't honestly answer the layered question.
|
|
441
|
+
#
|
|
442
|
+
# @param aggregate [Bluebook::Aggregate] the aggregate as the target era declares it
|
|
443
|
+
# @param era [Integer] ordinal of the era being previewed
|
|
444
|
+
# @param edges [Array<Hash{Symbol => Bluebook::Translation}>] one
|
|
445
|
+
# `{ translation: }` per edge, in mint order; the full chain, or one shorter
|
|
446
|
+
# @return [Hash{String => Hash}] parsed state keyed by aggregate id, for every
|
|
447
|
+
# id whose latest entry is a save; `{}` when nothing survives
|
|
448
|
+
# @raise [PG::Error] if the preview query fails, such as a convert rule
|
|
449
|
+
# meeting a value it does not map
|
|
450
|
+
# @raise [Runtime::WiringError] if a held era's text fails its digest check
|
|
358
451
|
def translated_latest(aggregate, era, edges)
|
|
359
452
|
latest_of(head_body_sql(aggregate, era, edges))
|
|
360
453
|
end
|
|
361
454
|
|
|
362
|
-
#
|
|
455
|
+
# Reads the ancestor tail with no edge applied and returns each surviving
|
|
456
|
+
# record's state exactly as its own era stored it.
|
|
457
|
+
#
|
|
458
|
+
# The untranslated ancestor tail, latest entry per id — the
|
|
363
459
|
# "before" side of every per-rule preservation check.
|
|
460
|
+
#
|
|
461
|
+
# @param aggregate [Bluebook::Aggregate] the aggregate as the target era declares it
|
|
462
|
+
# @param era [Integer] ordinal of the target era; ancestors are eras `1...era`
|
|
463
|
+
# @param edges [Array<Hash{Symbol => Bluebook::Translation}>] the edge chain,
|
|
464
|
+
# read only for the `was:` names the aggregate carried in each ancestor era
|
|
465
|
+
# @return [Hash{String => Hash}] parsed state keyed by aggregate id, for every
|
|
466
|
+
# id whose latest entry is a save; `{}` when there is no ancestor era
|
|
467
|
+
# @raise [PG::Error] if the query fails
|
|
468
|
+
# @raise [Runtime::WiringError] if a held era's text fails its digest check
|
|
364
469
|
def ancestor_latest(aggregate, era, edges)
|
|
365
470
|
names = names_by_era(aggregate, edges)
|
|
366
471
|
tail = ancestor_tail_sql(names, era)
|
|
@@ -369,6 +474,15 @@ module Hecks
|
|
|
369
474
|
latest_of("SELECT ordinal, era, aggregate_id, operation, state FROM (#{tail}) tail_rows")
|
|
370
475
|
end
|
|
371
476
|
|
|
477
|
+
# Runs a journal-shaped query and reduces it the way the head does: newest
|
|
478
|
+
# entry per aggregate id by ordinal, dropping any id whose newest entry is
|
|
479
|
+
# a delete.
|
|
480
|
+
#
|
|
481
|
+
# @param sql [String] a SELECT yielding at least `ordinal`, `aggregate_id`,
|
|
482
|
+
# `operation` and `state`
|
|
483
|
+
# @return [Hash{String => Hash}] `JSON.parse`d state keyed by aggregate id;
|
|
484
|
+
# `{}` when no id survives
|
|
485
|
+
# @raise [PG::Error] if the query fails
|
|
372
486
|
def latest_of(sql)
|
|
373
487
|
rows = @db.exec(<<~SQL)
|
|
374
488
|
SELECT aggregate_id, state FROM (
|
|
@@ -379,59 +493,90 @@ module Hecks
|
|
|
379
493
|
rows.to_h { |row| [row["aggregate_id"], JSON.parse(row["state"])] }
|
|
380
494
|
end
|
|
381
495
|
|
|
496
|
+
# Chooses the SQL a head's ancestor matview is built from: the layered
|
|
497
|
+
# build when it applies and `full` is off, otherwise the full chain.
|
|
498
|
+
#
|
|
499
|
+
# The one place that picks layered vs full — its own method so the
|
|
500
|
+
# audit's own preview (`translated_latest`, above: both
|
|
501
|
+
# `bin/translation_audit`'s standalone run and the real
|
|
502
|
+
# mint-time gate in coverage_check.rb#audit!) reads through the
|
|
503
|
+
# identical branch selection a real mint's own `compile_head!`
|
|
504
|
+
# will use, rather than a second, hand-kept-in-sync copy of this
|
|
505
|
+
# same "layered when eligible, else full" choice. A preview that
|
|
506
|
+
# called `chain_sql` unconditionally would let the real mint
|
|
507
|
+
# (`compile_head!`, `full: false` by default), for any era >= 3,
|
|
508
|
+
# materialize via the layered path while the
|
|
509
|
+
# preview that approved it never exercised that branch at all.
|
|
510
|
+
# `full:` mirrors `compile_head!`'s own default; tail_merge's
|
|
511
|
+
# `full: true` rebuild is the one caller that ever needs the
|
|
512
|
+
# non-layered branch unconditionally.
|
|
513
|
+
#
|
|
514
|
+
# @param aggregate [Bluebook::Aggregate] the aggregate as the target era declares it
|
|
515
|
+
# @param era [Integer] ordinal of the era being built
|
|
516
|
+
# @param edges [Array<Hash{Symbol => Bluebook::Translation}>] one
|
|
517
|
+
# `{ translation: }` per edge, in mint order
|
|
518
|
+
# @param full [Boolean] true skips the layered build and always chains from
|
|
519
|
+
# the raw journal
|
|
520
|
+
# @return [String] a SELECT yielding `ordinal, aggregate_id, operation, state`
|
|
521
|
+
# @raise [Runtime::WiringError] if a held era's text fails its digest check
|
|
522
|
+
# @raise [PG::Error] if reading `hecks_eras` or the catalog fails
|
|
523
|
+
def head_body_sql(aggregate, era, edges, full: false)
|
|
524
|
+
return chain_sql(aggregate, era, edges) if full
|
|
525
|
+
|
|
526
|
+
layered_chain_sql(aggregate, era, edges) || chain_sql(aggregate, era, edges)
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Builds one aggregate's head for a minted era: the ancestor matview, its
|
|
530
|
+
# reduce index, the era's snapshot table, and a head view recreated over
|
|
531
|
+
# the two. Runs inside the caller's mint or merge transaction.
|
|
532
|
+
#
|
|
382
533
|
# Era N: materialize the translated ancestor tail (edge chain as
|
|
383
|
-
# CTEs, watermarks baked in), then overlay this era's
|
|
534
|
+
# CTEs, watermarks baked in), then overlay this era's own live
|
|
384
535
|
# rows — read from its snapshot table, not re-derived from raw
|
|
385
536
|
# history — in a plain view.
|
|
386
537
|
#
|
|
387
|
-
#
|
|
388
|
-
# by construction only because
|
|
538
|
+
# **The frozen-tail invariant**. This materialized view is correct
|
|
539
|
+
# by construction only because both of these hold:
|
|
389
540
|
# 1. journal rows are never updated or deleted (immutability
|
|
390
541
|
# by privilege), and
|
|
391
|
-
# 2. the watermark is baked into this definition as a
|
|
392
|
-
# so post-cut ancestor writes — which
|
|
542
|
+
# 2. the watermark is baked into this definition as a literal,
|
|
543
|
+
# so post-cut ancestor writes — which do keep arriving
|
|
393
544
|
# while a fork is live — can never enter the head, even on
|
|
394
|
-
# a full REFRESH
|
|
395
|
-
# The ancestor partition is append-only but
|
|
545
|
+
# a full `REFRESH`.
|
|
546
|
+
# The ancestor partition is append-only but not frozen. Any
|
|
396
547
|
# future "optimization" that refreshes incrementally, reads the
|
|
397
548
|
# watermark from hecks_eras at query time, or otherwise
|
|
398
549
|
# re-derives the cut will silently leak the old world's post-cut
|
|
399
550
|
# writes into the new head. Rebuilding the definition (mint,
|
|
400
551
|
# merge) is the only way the cut may move.
|
|
552
|
+
#
|
|
401
553
|
# `full:` forces a rebuild from the raw journal. The tail-merge
|
|
402
|
-
# needs it: it moves
|
|
554
|
+
# needs it: it moves every watermark to the new tip, so every
|
|
403
555
|
# ancestor matview's cut goes stale in the same statement, and
|
|
404
556
|
# layering on one would carry a cut that no longer exists.
|
|
405
557
|
#
|
|
406
|
-
#
|
|
407
|
-
# over the raw journal —
|
|
408
|
-
#
|
|
558
|
+
# The live half reads the snapshot table, not `WHERE era = era AND
|
|
559
|
+
# aggregate = name` over the raw journal — that would be a
|
|
560
|
+
# DISTINCT ON re-reducing this era's
|
|
561
|
+
# entire write history on every single read, the exact cost the
|
|
409
562
|
# snapshot table exists to avoid (PostgresEra#append keeps it
|
|
410
|
-
# current, transactionally, as of every write). The union
|
|
411
|
-
# is bounded by
|
|
412
|
-
# write count; the ancestor side
|
|
563
|
+
# current, transactionally, as of every write). The head view's union
|
|
564
|
+
# is bounded by live record count for this era instead of its
|
|
565
|
+
# write count; the ancestor side is bounded the same way
|
|
413
566
|
# (the matview only ever holds the reduced tail, never raw
|
|
414
567
|
# history — see latest_per_id's own comment).
|
|
415
|
-
#
|
|
416
|
-
#
|
|
417
|
-
#
|
|
418
|
-
#
|
|
419
|
-
#
|
|
420
|
-
#
|
|
421
|
-
#
|
|
422
|
-
#
|
|
423
|
-
#
|
|
424
|
-
#
|
|
425
|
-
#
|
|
426
|
-
#
|
|
427
|
-
# `full: true` rebuild is the one caller that ever needs the
|
|
428
|
-
# non-layered branch unconditionally.
|
|
429
|
-
def head_body_sql(aggregate, era, edges, full: false)
|
|
430
|
-
return chain_sql(aggregate, era, edges) if full
|
|
431
|
-
|
|
432
|
-
layered_chain_sql(aggregate, era, edges) || chain_sql(aggregate, era, edges)
|
|
433
|
-
end
|
|
434
|
-
|
|
568
|
+
#
|
|
569
|
+
# @param aggregate [Bluebook::Aggregate] the aggregate as era `era` declares it
|
|
570
|
+
# @param era [Integer] ordinal of the era whose head is built, 2 or later
|
|
571
|
+
# @param label [String] the era's minted label, part of the matview's name
|
|
572
|
+
# @param edges [Array<Hash{Symbol => Bluebook::Translation}>] one
|
|
573
|
+
# `{ translation: }` per edge, in mint order, reaching `era`
|
|
574
|
+
# @param full [Boolean] true builds the matview from the raw journal rather
|
|
575
|
+
# than layering on era N-1's matview; the tail-merge passes it
|
|
576
|
+
# @return [void]
|
|
577
|
+
# @raise [PG::Error] if Postgres refuses a statement, including a matview of
|
|
578
|
+
# that name already existing or a convert rule meeting an unmapped value
|
|
579
|
+
# @raise [Runtime::WiringError] if a held era's text fails its digest check
|
|
435
580
|
def compile_head!(aggregate, era, label, edges, full: false)
|
|
436
581
|
storage_name = aggregate.storage_name
|
|
437
582
|
view = matview(storage_name, era, label)
|
|
@@ -440,10 +585,10 @@ module Hecks
|
|
|
440
585
|
CREATE MATERIALIZED VIEW #{quote(view)} AS
|
|
441
586
|
#{body}
|
|
442
587
|
SQL
|
|
443
|
-
#
|
|
588
|
+
# Additive, changes nothing about what can be pushed through
|
|
444
589
|
# the reduction (that wall is structural, an index doesn't
|
|
445
590
|
# move it — see this file's own module header) — only speeds
|
|
446
|
-
# up the reduction
|
|
591
|
+
# up the reduction itself, which every read still has to run
|
|
447
592
|
# regardless of a field cache's own two-phase shortcut (a
|
|
448
593
|
# multi-clause query with an uncached clause, an order_by-only
|
|
449
594
|
# query, or simply every read before Track C's cache tables
|
|
@@ -452,26 +597,26 @@ module Hecks
|
|
|
452
597
|
# running on PG18+.
|
|
453
598
|
@db.exec("CREATE INDEX IF NOT EXISTS #{quote("#{view}_reduce_idx")} ON #{quote(view)} (aggregate_id, ordinal DESC)")
|
|
454
599
|
|
|
455
|
-
# era-qualified, always a
|
|
600
|
+
# era-qualified, always a fresh table for a newly-minted era —
|
|
456
601
|
# no separate reset step needed; there is structurally nothing
|
|
457
602
|
# in it yet for anyone to have written, until an append lands
|
|
458
603
|
# under this era specifically.
|
|
459
604
|
ensure_head_snapshot!(storage_name, era)
|
|
460
605
|
@db.exec("DROP VIEW IF EXISTS #{quote(head_view(storage_name))}")
|
|
461
|
-
#
|
|
462
|
-
# H3 (docs/audits/2026-08-10-main-bug-audit.md)
|
|
463
|
-
#
|
|
464
|
-
# delete simply
|
|
465
|
-
#
|
|
466
|
-
# ancestor matview's own `save` row as the
|
|
467
|
-
# side of this union — which then
|
|
468
|
-
# `WHERE operation = 'save'`
|
|
469
|
-
# deleted ancestor-carried record
|
|
470
|
-
# The snapshot side
|
|
606
|
+
# The current-era side reads its own `operation` column —
|
|
607
|
+
# H3 (docs/audits/2026-08-10-main-bug-audit.md) — rather than
|
|
608
|
+
# hardcoding `'save' AS operation` on the reasoning that a
|
|
609
|
+
# delete simply removes its row from the snapshot table. For
|
|
610
|
+
# an id carried in from an ancestor era, a removed row leaves the
|
|
611
|
+
# ancestor matview's own `save` row as the only row on either
|
|
612
|
+
# side of this union — which then wins `DISTINCT ON` and
|
|
613
|
+
# `WHERE operation = 'save'` lets it straight through, so a
|
|
614
|
+
# deleted ancestor-carried record keeps resurrecting forever.
|
|
615
|
+
# The snapshot side upserts a real tombstone row on
|
|
471
616
|
# delete (`ensure_head_snapshot!`'s own comment) instead of
|
|
472
617
|
# deleting the row, so reading its actual `operation` here
|
|
473
618
|
# lets that tombstone outrank the stale ancestor `save` row
|
|
474
|
-
# by ordinal, exactly the way a genuine re-save
|
|
619
|
+
# by ordinal, exactly the way a genuine re-save does.
|
|
475
620
|
@db.exec(<<~SQL)
|
|
476
621
|
CREATE VIEW #{quote(head_view(storage_name))} AS
|
|
477
622
|
SELECT id, state FROM (
|
|
@@ -485,10 +630,20 @@ module Hecks
|
|
|
485
630
|
SQL
|
|
486
631
|
end
|
|
487
632
|
|
|
488
|
-
#
|
|
633
|
+
# Resolves the name an aggregate carried in every era of the chain, so each
|
|
634
|
+
# ancestor era's journal rows can be found under the name of their time.
|
|
635
|
+
#
|
|
636
|
+
# The aggregate's storage name as of each era: `was:` chains
|
|
489
637
|
# walked backward from the current name, one edge at a time.
|
|
490
638
|
# names[:current][i] is the declared (Pascal) name after edge i;
|
|
491
|
-
# names[:storage][
|
|
639
|
+
# names[:storage][i] its snake-cased storage name, so era e
|
|
640
|
+
# (1-based) is `names[:storage][e - 1]`.
|
|
641
|
+
#
|
|
642
|
+
# @param aggregate [Bluebook::Aggregate] the aggregate under its current name
|
|
643
|
+
# @param edges [Array<Hash{Symbol => Bluebook::Translation}>] one
|
|
644
|
+
# `{ translation: }` per edge, in mint order
|
|
645
|
+
# @return [Hash{Symbol => Array<String>}] `:current` and `:storage`, each of
|
|
646
|
+
# `edges.size + 1` names, index 0 being era 1
|
|
492
647
|
def names_by_era(aggregate, edges)
|
|
493
648
|
current = Array.new(edges.size + 1)
|
|
494
649
|
current[edges.size] = aggregate.name
|
|
@@ -500,9 +655,20 @@ module Hecks
|
|
|
500
655
|
{ current: current, storage: storage }
|
|
501
656
|
end
|
|
502
657
|
|
|
658
|
+
# Builds the `UNION ALL` of one journal SELECT per ancestor era, the raw
|
|
659
|
+
# input every translation chain starts from.
|
|
660
|
+
#
|
|
503
661
|
# Rows this aggregate contributed to every ancestor era, under
|
|
504
|
-
# the name it had
|
|
662
|
+
# the name it had then, cut at the watermark recorded when the
|
|
505
663
|
# next era was minted.
|
|
664
|
+
#
|
|
665
|
+
# @param names [Hash{Symbol => Array<String>}] the `names_by_era` result; only
|
|
666
|
+
# `:storage` is read
|
|
667
|
+
# @param era [Integer] ordinal of the target era; ancestors are eras `1...era`
|
|
668
|
+
# @return [String] the unioned SELECT, or `""` when `era` is 1; an ancestor
|
|
669
|
+
# whose successor has no recorded watermark is read uncut
|
|
670
|
+
# @raise [Runtime::WiringError] if a held era's text fails its digest check
|
|
671
|
+
# @raise [PG::Error] if reading `hecks_eras` fails
|
|
506
672
|
def ancestor_tail_sql(names, era)
|
|
507
673
|
watermarks = eras.to_h { |held| [held[:ordinal], held[:watermark]] }
|
|
508
674
|
selects = (1...era).map do |ancestor|
|
|
@@ -515,13 +681,20 @@ module Hecks
|
|
|
515
681
|
end
|
|
516
682
|
|
|
517
683
|
# compile_rules/rekeyed?/id_case/compile_id_expression/
|
|
518
|
-
# compile_compute
|
|
519
|
-
#
|
|
520
|
-
# watermark, no era chain.
|
|
684
|
+
# compile_compute live in Translation::RuleCompiler — the
|
|
685
|
+
# pure half of this compiler, with no database connection, no
|
|
686
|
+
# watermark, no era chain. Separate so Exporter's build-time
|
|
521
687
|
# SQL export (feeding rust/host's own boot-time mint) can call
|
|
522
688
|
# the exact same code this file's own callers do, rather than
|
|
523
689
|
# a hand-ported duplicate. See that module's own header.
|
|
524
690
|
|
|
691
|
+
# Tells whether a view or materialized view of this name is visible on the
|
|
692
|
+
# connection's search path; a table of the same name does not count.
|
|
693
|
+
#
|
|
694
|
+
# @param name [String] unquoted relation name
|
|
695
|
+
# @return [Boolean] true when a visible `pg_class` row of kind `v` or `m` has
|
|
696
|
+
# that name
|
|
697
|
+
# @raise [PG::Error] if the catalog lookup fails
|
|
525
698
|
def view_exists?(name)
|
|
526
699
|
# pg_class + pg_table_is_visible, not information_schema/
|
|
527
700
|
# pg_matviews by bare name — same shared-instance reasoning
|