hecks 1.3.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 +5 -4
- 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/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 +18 -18
- data/lib/hecks/behaviors/dsl.rb +2 -2
- data/lib/hecks/behaviors/expectations.rb +23 -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 +7 -7
- 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 +101 -87
- 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 +170 -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 +180 -19
- 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 +140 -45
- 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 +37 -18
- data/lib/hecks/fuzzing/form_census.rb +35 -20
- 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 +62 -62
- data/lib/hecks/fuzzing/nondeterministic.rb +67 -0
- data/lib/hecks/fuzzing/persistence_parity.rb +19 -21
- data/lib/hecks/fuzzing/properties/corrections.rb +9 -9
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +154 -49
- data/lib/hecks/fuzzing/properties/guards.rb +42 -42
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +32 -32
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +22 -35
- data/lib/hecks/fuzzing/properties/outbox.rb +24 -24
- data/lib/hecks/fuzzing/properties/querying.rb +17 -17
- data/lib/hecks/fuzzing/properties.rb +64 -40
- data/lib/hecks/fuzzing/qa_settings.rb +152 -0
- data/lib/hecks/fuzzing/replay.rb +114 -114
- data/lib/hecks/fuzzing/rotation_priority.rb +10 -10
- data/lib/hecks/fuzzing/rust_gap_manifest.rb +113 -0
- data/lib/hecks/fuzzing/self_consistency.rb +141 -112
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +35 -35
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +18 -11
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +13 -12
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +21 -12
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +58 -31
- data/lib/hecks/fuzzing/sequence_generator.rb +111 -23
- data/lib/hecks/fuzzing/shrinker.rb +197 -0
- data/lib/hecks/fuzzing/structural_skips.rb +23 -130
- data/lib/hecks/fuzzing/sweep_depth.rb +4 -4
- data/lib/hecks/fuzzing/target_capabilities.rb +49 -18
- data/lib/hecks/fuzzing/value_generator.rb +14 -14
- data/lib/hecks/fuzzing.rb +5 -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 +348 -13
- data/lib/hecks/language/oidc.json +5 -0
- data/lib/hecks/literal.rb +9 -9
- data/lib/hecks/naming.rb +25 -25
- 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 +118 -66
- 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 +33 -9
- 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 +314 -90
- 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 +23 -3
- 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 +3 -3
- data/lib/hecks/projections/glossary/markdown.rb +3 -3
- data/lib/hecks/projections/glossary/mermaid.rb +1 -1
- data/lib/hecks/projections/glossary/sections.rb +1 -1
- data/lib/hecks/projections/glossary/sentences.rb +5 -5
- data/lib/hecks/projections/glossary.rb +15 -15
- 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 +11 -11
- 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 +109 -27
- 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 +42 -63
- 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 +35 -35
- 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 +151 -67
- data/lib/hecks/runtime/entity_interpreter.rb +108 -82
- 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 +55 -64
- data/lib/hecks/runtime/reaction_invocation.rb +20 -20
- 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 -115
- 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 +45 -28
- data/lib/hecks/runtime/remote_dispatcher.rb +38 -23
- data/lib/hecks/runtime/routing.rb +10 -153
- data/lib/hecks/runtime/saga_interpreter/correlation.rb +17 -17
- data/lib/hecks/runtime/saga_interpreter.rb +64 -64
- 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 +318 -127
- data/lib/hecks/runtime/value/entity_list_coercion.rb +34 -34
- data/lib/hecks/runtime/value.rb +21 -21
- 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 +205 -4
- 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 +22 -2
|
@@ -2,56 +2,67 @@ module Hecks
|
|
|
2
2
|
module Adapters
|
|
3
3
|
class PostgresEra
|
|
4
4
|
class Lineage
|
|
5
|
-
#
|
|
6
|
-
# `backfill_head_snapshot!` (era 1's
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# split-plan.md): no
|
|
10
|
-
#
|
|
11
|
-
# `INSERT ... SELECT`
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# backfill.
|
|
5
|
+
# The one chunked, lock-free, resumable backfill loop — shared by
|
|
6
|
+
# `backfill_head_snapshot!` (era 1's head-snapshot backfill) and
|
|
7
|
+
# every field-cache table's own initial backfill. Governing
|
|
8
|
+
# principle 1
|
|
9
|
+
# (docs/implemented/postgres-era-adapter-split-plan.md): no
|
|
10
|
+
# operation this plan touches may hold a lock across a scan whose
|
|
11
|
+
# duration scales with table size — a single `INSERT ... SELECT`
|
|
12
|
+
# over the whole journal (the one-shot form of
|
|
13
|
+
# `backfill_head_snapshot!`) is exactly that, and so is a naive
|
|
14
|
+
# "populate every cache row in one statement" field-cache backfill.
|
|
15
15
|
#
|
|
16
|
-
#
|
|
16
|
+
# ## The shape
|
|
17
|
+
#
|
|
18
|
+
# Read one bounded chunk (real rows, real ordinals) with
|
|
17
19
|
# a plain SELECT — no lock held across it, so an ordinary reader or
|
|
18
20
|
# writer is never blocked by a backfill in progress — then upsert
|
|
19
|
-
# that chunk under the
|
|
21
|
+
# that chunk under the same transactionally-scoped advisory lock +
|
|
20
22
|
# ordinal-guard idiom `append`'s own snapshot upsert already uses
|
|
21
23
|
# (`WHERE ordinal < EXCLUDED.ordinal`), then persist a cursor
|
|
22
24
|
# before moving to the next chunk. Repeat until a chunk reads back
|
|
23
25
|
# short of a full page — that page was the last one.
|
|
24
26
|
#
|
|
25
|
-
#
|
|
27
|
+
# ## Resumable, not merely restartable
|
|
28
|
+
#
|
|
29
|
+
# A crash (or a second
|
|
26
30
|
# concurrent boot) mid-backfill leaves the cursor exactly where the
|
|
27
|
-
# last
|
|
28
|
-
# updated in the
|
|
31
|
+
# last committed chunk left it — `hecks_backfill_progress` is
|
|
32
|
+
# updated in the same transaction as the chunk's own upsert, so
|
|
29
33
|
# cursor and data can never observably disagree (see
|
|
30
34
|
# `run_chunk!`). The next attempt reads that cursor and continues;
|
|
31
35
|
# it does not rescan what a prior attempt already committed.
|
|
32
|
-
#
|
|
36
|
+
# Restartable would also be correct here (every upsert is
|
|
33
37
|
# idempotent and ordinal-guarded — rerunning an already-done chunk
|
|
34
38
|
# from id 1 changes nothing) but wastes real work on a large
|
|
35
|
-
# table; resumability is what keeps a crash near the
|
|
39
|
+
# table; resumability is what keeps a crash near the end of a
|
|
36
40
|
# large backfill cheap to recover from instead of starting over.
|
|
37
41
|
#
|
|
38
|
-
#
|
|
42
|
+
# ## The lock key
|
|
43
|
+
#
|
|
44
|
+
# The lock key prefix is `hecks_field_cache:` — deliberately
|
|
39
45
|
# disjoint from the three families already in use elsewhere in
|
|
40
46
|
# this adapter (`hecks_ordinal:`, `hecks_eras:`,
|
|
41
47
|
# `hecks_head_snapshot:` — see lineage.rb/head_compiler.rb/
|
|
42
|
-
# mint_transaction.rb/tail_merge.rb) so a backfill chunk
|
|
48
|
+
# mint_transaction.rb/tail_merge.rb) so a backfill chunk never
|
|
43
49
|
# contends with a plain write, a mint, or a snapshot-table's own
|
|
44
|
-
# first-creation lock. It is held for exactly
|
|
50
|
+
# first-creation lock. It is held for exactly one chunk's own
|
|
45
51
|
# transaction, never across the whole backfill — two concurrent
|
|
46
|
-
# backfillers of the
|
|
52
|
+
# backfillers of the same target simply take turns one chunk at a
|
|
47
53
|
# time rather than racing to duplicate work; neither blocks an
|
|
48
54
|
# unrelated reader or writer for even an instant.
|
|
49
55
|
module ResumableBackfill
|
|
50
56
|
CHUNK_SIZE = 5_000
|
|
51
57
|
|
|
58
|
+
# Creates `hecks_backfill_progress`, the table of per-target backfill cursors, if absent.
|
|
59
|
+
#
|
|
52
60
|
# Idempotent, unguarded — same idiom as every other DDL helper
|
|
53
61
|
# in this file tree (`ensure_head_snapshot!` et al.): cheap,
|
|
54
62
|
# runs on every boot, only ever does real work once.
|
|
63
|
+
#
|
|
64
|
+
# @return [void]
|
|
65
|
+
# @raise [PG::Error] if Postgres refuses the DDL
|
|
55
66
|
def ensure_backfill_progress_table!
|
|
56
67
|
@db.exec(<<~SQL)
|
|
57
68
|
CREATE TABLE IF NOT EXISTS hecks_backfill_progress (
|
|
@@ -63,13 +74,15 @@ module Hecks
|
|
|
63
74
|
SQL
|
|
64
75
|
end
|
|
65
76
|
|
|
77
|
+
# Fills `target` one committed chunk at a time, resuming from its stored cursor.
|
|
78
|
+
#
|
|
66
79
|
# Drives `target` (an already-created, currently-empty-or-
|
|
67
80
|
# partially-filled table) through chunks until a source read
|
|
68
81
|
# comes back short of `CHUNK_SIZE` rows. Two distinct callables,
|
|
69
82
|
# not one — a head-snapshot row and a field-cache row carry
|
|
70
83
|
# different columns (`state` jsonb vs. a single extracted
|
|
71
84
|
# `value`), so there is no one generic "upsert this row" shape
|
|
72
|
-
# to share; only the
|
|
85
|
+
# to share; only the loop, the lock, and the cursor are generic.
|
|
73
86
|
#
|
|
74
87
|
# source_sql.call(cursor) — given the last-processed id (nil
|
|
75
88
|
# before the first chunk), returns a SQL SELECT whose result
|
|
@@ -82,11 +95,21 @@ module Hecks
|
|
|
82
95
|
#
|
|
83
96
|
# upsert.call(rows) — given the PG::Result of one chunk's
|
|
84
97
|
# read, performs the actual guarded upsert into `target` and
|
|
85
|
-
# returns nothing meaningful; runs
|
|
98
|
+
# returns nothing meaningful; runs inside the same
|
|
86
99
|
# transaction/advisory-lock scope as the cursor update below,
|
|
87
100
|
# so a crash between "wrote the chunk" and "advanced the
|
|
88
101
|
# cursor" is impossible — they commit together or not at
|
|
89
102
|
# all.
|
|
103
|
+
#
|
|
104
|
+
# @param target [String] unquoted name of the table to fill; also the progress row's key
|
|
105
|
+
# and the advisory-lock key
|
|
106
|
+
# @param source_sql [#call] callable given the cursor (`String` id of the last row
|
|
107
|
+
# processed, nil before the first chunk) that returns the chunk's SELECT as a `String`
|
|
108
|
+
# @param upsert [#call] callable given one chunk's `PG::Result` that writes it into
|
|
109
|
+
# `target`; its return value is ignored
|
|
110
|
+
# @return [void]
|
|
111
|
+
# @raise [PG::Error] if Postgres refuses the progress-table DDL, a source read, or a
|
|
112
|
+
# cursor update; whatever `upsert` raises propagates too, rolling back that chunk
|
|
90
113
|
def chunked_backfill!(target, source_sql:, upsert:)
|
|
91
114
|
ensure_backfill_progress_table!
|
|
92
115
|
loop do
|
|
@@ -97,8 +120,8 @@ module Hecks
|
|
|
97
120
|
|
|
98
121
|
private
|
|
99
122
|
|
|
100
|
-
#
|
|
101
|
-
# progress
|
|
123
|
+
# One chunk, one transaction, one short-held lock. Re-reads
|
|
124
|
+
# progress after acquiring the lock (not just before) — a second
|
|
102
125
|
# concurrent booter may have already finished this exact chunk
|
|
103
126
|
# (or the whole backfill) while this process was waiting for the
|
|
104
127
|
# lock; without the re-read, it would redundantly reprocess a
|
|
@@ -128,7 +151,7 @@ module Hecks
|
|
|
128
151
|
|
|
129
152
|
upsert.call(rows)
|
|
130
153
|
completed = rows.ntuples < CHUNK_SIZE
|
|
131
|
-
# `PG::Result#[]` supports neither an out-of-range index
|
|
154
|
+
# `PG::Result#[]` supports neither an out-of-range index nor
|
|
132
155
|
# a negative one (unlike a plain Ruby Array) — same trap as
|
|
133
156
|
# `backfill_progress` above, the explicit last-index form.
|
|
134
157
|
last_cursor = rows[rows.ntuples - 1]["id"]
|
|
@@ -137,9 +160,9 @@ module Hecks
|
|
|
137
160
|
completed
|
|
138
161
|
end
|
|
139
162
|
|
|
140
|
-
# `PG::Result#[]`
|
|
163
|
+
# `PG::Result#[]` raises IndexError on an out-of-range index —
|
|
141
164
|
# unlike a plain Ruby Array, it does not return nil — so the
|
|
142
|
-
#
|
|
165
|
+
# entirely ordinary case of "no progress row exists yet" (every
|
|
143
166
|
# target's very first check) cannot be read via a bare `[0]`.
|
|
144
167
|
# `ntuples.zero?` first, always.
|
|
145
168
|
def backfill_progress(target)
|
|
@@ -11,8 +11,15 @@ module Hecks
|
|
|
11
11
|
module TailMerge
|
|
12
12
|
# ── fork observability ─────────────────────────────────────────
|
|
13
13
|
|
|
14
|
+
# Counts the journal rows an old era wrote after the next era's cut.
|
|
15
|
+
#
|
|
14
16
|
# Post-cut writes an old era made after the newer era was minted
|
|
15
17
|
# — the divergence between the worlds, observable at any time.
|
|
18
|
+
#
|
|
19
|
+
# @param old_era [Integer] ordinal of the superseded era to measure
|
|
20
|
+
# @return [Integer] number of `old_era` rows past the watermark of era `old_era + 1`;
|
|
21
|
+
# 0 when the domain holds no such newer era
|
|
22
|
+
# @raise [Runtime::WiringError] if a held era's text fails its integrity check
|
|
16
23
|
def diverged_count(old_era)
|
|
17
24
|
cut = eras.find { |era| era[:ordinal] == old_era + 1 }&.dig(:watermark)
|
|
18
25
|
return 0 unless cut
|
|
@@ -24,6 +31,8 @@ module Hecks
|
|
|
24
31
|
|
|
25
32
|
# ── tail-merge ─────────────────────────────────────────────────
|
|
26
33
|
#
|
|
34
|
+
# Folds every old era's post-cut writes into the current era's heads, under named winners.
|
|
35
|
+
#
|
|
27
36
|
# The one deliberate command — it marks a business event (an app
|
|
28
37
|
# retiring), never a shape change. One transaction: advance the
|
|
29
38
|
# watermarks, rebuild the head so the tail interleaves by its
|
|
@@ -34,13 +43,27 @@ module Hecks
|
|
|
34
43
|
# re-enters as the newest row and wins structurally — originals
|
|
35
44
|
# stay immutable).
|
|
36
45
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
37
|
-
# One Postgres transaction (BEGIN
|
|
46
|
+
# One Postgres transaction (`BEGIN`…`COMMIT`) with a manual `ROLLBACK`
|
|
38
47
|
# at every early refusal, and a snapshot-before-mutation
|
|
39
48
|
# invariant (`new_states` captured before the head rebuild lets
|
|
40
49
|
# the tail interleave). Splitting the steps into separate
|
|
41
50
|
# methods would force each to independently know how to roll
|
|
42
51
|
# back the same shared transaction, and would separate the
|
|
43
52
|
# snapshot from the mutation it must precede.
|
|
53
|
+
#
|
|
54
|
+
# @param aggregates [Array<Bluebook::Aggregate>] the current bluebook's aggregates, each
|
|
55
|
+
# of which gets its head rebuilt
|
|
56
|
+
# @param edges [Array<Hash{Symbol => Bluebook::Translation}>] the full edge chain in mint
|
|
57
|
+
# order, one `{ translation: }` Hash per step, as `LineageManager.edge_chain` builds it
|
|
58
|
+
# @param winners [Hash{String => String}] aggregate id to winning side, `"old"` or
|
|
59
|
+
# `"new"`; the winner's whole state is appended as the newest row
|
|
60
|
+
# @param audit [#call, nil] callable run just before `COMMIT` that returns an
|
|
61
|
+
# `Array<String>` of violations, empty to pass; nil skips the audit
|
|
62
|
+
# @return [true] always, once the merge is committed; every refusal raises instead
|
|
63
|
+
# @raise [Runtime::WiringError] if the domain stands at era 1, if an id touched by both
|
|
64
|
+
# worlds since the cut has no entry in `winners`, if `audit` reports a violation, if
|
|
65
|
+
# another mint or merge holds the domain lock for over 10s, if Postgres refuses any
|
|
66
|
+
# statement, or if a held era's text fails its integrity check
|
|
44
67
|
def merge_tail!(aggregates:, edges:, winners: {}, audit: nil)
|
|
45
68
|
@db.exec("BEGIN")
|
|
46
69
|
@db.exec("SET LOCAL lock_timeout = '10s'")
|
|
@@ -159,18 +182,18 @@ module Hecks
|
|
|
159
182
|
end
|
|
160
183
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
161
184
|
|
|
162
|
-
# Ids touched by
|
|
185
|
+
# Ids touched by both worlds since the cut — the old world's
|
|
163
186
|
# post-cut tail INTERSECTed with the new world's own writes.
|
|
164
187
|
#
|
|
165
|
-
#
|
|
188
|
+
# Known gap, not silently risked: this compares raw
|
|
166
189
|
# `aggregate_id` values, with no notion of "these two different
|
|
167
190
|
# ids are the same entity, rekeyed." If a domain's history
|
|
168
|
-
# includes a rekey (see TranslationRekey) and is
|
|
191
|
+
# includes a rekey (see TranslationRekey) and is later
|
|
169
192
|
# merged here, a record's pre-rekey and post-rekey rows will
|
|
170
193
|
# never intersect — they just silently survive as two separate,
|
|
171
194
|
# unrelated-looking heads (a duplicate, not corruption: nothing
|
|
172
195
|
# here deletes or clobbers either side). Resolve any such
|
|
173
|
-
# duplicate manually after a merge; teaching this
|
|
196
|
+
# duplicate manually after a merge; teaching this intersect
|
|
174
197
|
# about a rekey mapping is real, separate work, deliberately
|
|
175
198
|
# out of scope for rekey's first pass.
|
|
176
199
|
def conflict_ids(aggregate, edges, era, cut)
|
|
@@ -9,16 +9,18 @@ module Hecks
|
|
|
9
9
|
# the cross-execution equivalence spec, never a second source of
|
|
10
10
|
# truth.
|
|
11
11
|
module TransformInstaller
|
|
12
|
+
# Installs or replaces the six `hecks_tr_*` functions under a database-wide advisory lock.
|
|
13
|
+
#
|
|
12
14
|
# The jsonb rule transforms — installed once, idempotently. Kept
|
|
13
15
|
# equal to the port's reference entry-JSON transform by the
|
|
14
16
|
# cross-execution equivalence spec; the SQL here is a compilation
|
|
15
17
|
# target, not a second source of truth.
|
|
16
18
|
#
|
|
17
|
-
#
|
|
19
|
+
# Locked, unlike every other statement `ensure_base!` runs — those
|
|
18
20
|
# are all `CREATE ... IF NOT EXISTS`/`ADD COLUMN IF NOT EXISTS`,
|
|
19
21
|
# which Postgres itself resolves safely under concurrent boots.
|
|
20
22
|
# `CREATE OR REPLACE FUNCTION` is not: it always rewrites the
|
|
21
|
-
# `pg_proc` row, so two sessions racing to (re)install the
|
|
23
|
+
# `pg_proc` row, so two sessions racing to (re)install the same
|
|
22
24
|
# function — these six are shared/global, not per-domain, so any
|
|
23
25
|
# two domains' concurrent first-boots can collide here — hit a
|
|
24
26
|
# real `PG::InternalError: tuple concurrently updated`, not a
|
|
@@ -26,6 +28,9 @@ module Hecks
|
|
|
26
28
|
# already-open-transaction-safe wrapper `ensure_field_cache!`
|
|
27
29
|
# uses for its own advisory lock; a fixed, domain-independent key
|
|
28
30
|
# is correct since these functions have no domain of their own.
|
|
31
|
+
#
|
|
32
|
+
# @return [void]
|
|
33
|
+
# @raise [PG::Error] if Postgres refuses the lock or a function definition
|
|
29
34
|
def install_transforms!
|
|
30
35
|
nested_transaction("hecks_tr_functions") do
|
|
31
36
|
@db.exec_params("SELECT pg_advisory_xact_lock(hashtext('hecks_tr_functions'))", [])
|
|
@@ -33,14 +38,21 @@ module Hecks
|
|
|
33
38
|
end
|
|
34
39
|
end
|
|
35
40
|
|
|
36
|
-
#
|
|
41
|
+
# Runs the six function definitions with no lock of its own; `install_transforms!` is
|
|
42
|
+
# the locked entry point.
|
|
43
|
+
#
|
|
44
|
+
# Six independent `CREATE OR REPLACE FUNCTION` statements — each
|
|
37
45
|
# self-contained SQL, no shared Ruby state, and (per this
|
|
38
46
|
# module's own header comment) safe in any install order since
|
|
39
47
|
# plpgsql bodies aren't resolved against each other until
|
|
40
|
-
# called, not at
|
|
48
|
+
# called, not at create time. Split one-per-function below
|
|
41
49
|
# purely so each has its own name and (where relevant) its own
|
|
42
50
|
# comment to sit next to, not because the six have any
|
|
43
51
|
# sequencing dependency on one another.
|
|
52
|
+
#
|
|
53
|
+
# @return [void]
|
|
54
|
+
# @raise [PG::Error] if Postgres refuses a definition, including
|
|
55
|
+
# `tuple concurrently updated` when two unlocked sessions race
|
|
44
56
|
def install_transform_functions!
|
|
45
57
|
install_hecks_tr_extract!
|
|
46
58
|
install_hecks_tr_insert!
|
|
@@ -84,15 +96,16 @@ module Hecks
|
|
|
84
96
|
SQL
|
|
85
97
|
end
|
|
86
98
|
|
|
87
|
-
#
|
|
99
|
+
# **Adversarial finding**: a destination whose top segment already
|
|
88
100
|
# holds a value — most commonly a reference, a bare scalar id
|
|
89
|
-
# —
|
|
90
|
-
# moment a dotted destination
|
|
91
|
-
# a drop that never declared itself, the one
|
|
92
|
-
# language exists to make explicit (see
|
|
93
|
-
# refusal below, the same shape)
|
|
94
|
-
#
|
|
95
|
-
# `insert`)
|
|
101
|
+
# — is refused rather than silently overwritten with an empty
|
|
102
|
+
# object the moment a dotted destination needs to nest under it.
|
|
103
|
+
# Overwriting is a drop that never declared itself, the one
|
|
104
|
+
# thing this language exists to make explicit (see
|
|
105
|
+
# hecks_tr_convert's own refusal below, the same shape). The
|
|
106
|
+
# Ruby reference transform
|
|
107
|
+
# (ports/persistence/plugins/era/lineage.rb's `insert`) raises
|
|
108
|
+
# the identical wording.
|
|
96
109
|
def install_hecks_tr_insert!
|
|
97
110
|
@db.exec(<<~SQL)
|
|
98
111
|
CREATE OR REPLACE FUNCTION hecks_tr_insert(state jsonb, path text[], value jsonb, rule_label text) RETURNS jsonb
|
|
@@ -15,65 +15,75 @@ module Hecks
|
|
|
15
15
|
module Adapters
|
|
16
16
|
class PostgresEra
|
|
17
17
|
# The lineage topology inside one Postgres database: one journal
|
|
18
|
-
# per domain,
|
|
18
|
+
# per domain, list-partitioned by era, with a single ordinal
|
|
19
19
|
# sequence spanning partitions (total order across eras is
|
|
20
20
|
# structural); a `hecks_eras` table holding each era's frozen
|
|
21
21
|
# source text, its once-minted hash/label, and the watermark cut
|
|
22
|
-
# into its ancestor; and, per aggregate, a
|
|
22
|
+
# into its ancestor; and, per aggregate, a head derived from the
|
|
23
23
|
# journal — never a table anything rewrites.
|
|
24
24
|
#
|
|
25
|
+
# ## How a head is derived
|
|
26
|
+
#
|
|
25
27
|
# For era 1 the head is a plain view (latest save per id). From
|
|
26
28
|
# era 2 on, the ancestor tail is a MATERIALIZED view whose
|
|
27
29
|
# definition is the compiled, chained edge sequence — one CTE per
|
|
28
30
|
# original edge, in mint order, never a flattened merged rule set
|
|
29
31
|
# — with the watermark baked into the definition, so post-cut
|
|
30
|
-
# old-era writes cannot leak into the new head even on REFRESH
|
|
32
|
+
# old-era writes cannot leak into the new head even on `REFRESH`.
|
|
31
33
|
# Live current-era writes overlay it through the head view.
|
|
32
34
|
#
|
|
35
|
+
# ## The current-era write fence
|
|
36
|
+
#
|
|
33
37
|
# Writing to a superseded schema drops the instant the new one
|
|
34
|
-
# materializes:
|
|
38
|
+
# materializes: one shared row policy admits INSERTs to whichever
|
|
35
39
|
# era was just established (era 1 at first hold, era N at mint),
|
|
36
|
-
# and advancing it is part of the
|
|
40
|
+
# and advancing it is part of the same transaction that builds the
|
|
37
41
|
# new era's matview. There is no persisted per-role fork — any
|
|
38
|
-
# granted role, app or the table's own
|
|
39
|
-
# SECURITY applies this to the owner too, not only
|
|
40
|
-
# roles), writes the current era or nothing, from the moment that
|
|
42
|
+
# granted role, app or the table's own owner
|
|
43
|
+
# (`FORCE ROW LEVEL SECURITY` applies this to the owner too, not only
|
|
44
|
+
# ordinary roles), writes the current era or nothing, from the moment that
|
|
41
45
|
# transaction commits. A stale-era write during the narrow window
|
|
42
46
|
# before that commit — RLS is checked once, when the statement
|
|
43
|
-
#
|
|
47
|
+
# executes, never re-checked at commit, so a transaction that
|
|
44
48
|
# inserted while the old era was still current can still land
|
|
45
49
|
# after a concurrent mint has already moved the fence on — is
|
|
46
|
-
# exactly what diverged_count
|
|
47
|
-
# the residual of an unavoidable race (ordinals are
|
|
50
|
+
# exactly what `diverged_count`/`merge_tail!` exist to reconcile; it
|
|
51
|
+
# is the residual of an unavoidable race (ordinals are
|
|
48
52
|
# sequence-assigned, not transactional — see below), not a
|
|
49
53
|
# supported way to keep operating two schemas side by side. Only
|
|
50
54
|
# an actual Postgres superuser (or a role granted BYPASSRLS)
|
|
51
|
-
# sits above FORCE and keeps writing at will, forever.
|
|
55
|
+
# sits above `FORCE` and keeps writing at will, forever.
|
|
56
|
+
#
|
|
57
|
+
# ## Ordinal order and commit order
|
|
52
58
|
#
|
|
53
59
|
# Lineage order is ordinal-assignment order: the ordinal comes from a
|
|
54
60
|
# sequence, and a sequence's `nextval()` is never rolled back with its
|
|
55
61
|
# transaction — accepted and documented rather than papered over.
|
|
56
62
|
#
|
|
57
|
-
#
|
|
58
|
-
# `nextval()` in one order and COMMIT in the
|
|
59
|
-
# single autocommit INSERT statement stops a
|
|
60
|
-
# after a faster one that started later —
|
|
61
|
-
# "commit order"
|
|
62
|
-
# mint anywhere near either write.
|
|
63
|
+
# **One part of that is closed**. Left unserialized, two concurrent
|
|
64
|
+
# plain writes can call `nextval()` in one order and COMMIT in the
|
|
65
|
+
# other — nothing about a single autocommit INSERT statement stops a
|
|
66
|
+
# slower one from finishing after a faster one that started later —
|
|
67
|
+
# which makes "ordinal order" and "commit order" formally two different
|
|
68
|
+
# total orders even with no mint anywhere near either write. So
|
|
69
|
+
# `PostgresEra#append` holds
|
|
63
70
|
# `pg_advisory_xact_lock(hashtext('hecks_ordinal:' || domain))` for the
|
|
64
|
-
# length of its own transaction, a
|
|
65
|
-
# `merge_tail!`'s `hecks_eras:domain` —
|
|
66
|
-
#
|
|
67
|
-
#
|
|
71
|
+
# length of its own transaction, a different key from `mint_era!` and
|
|
72
|
+
# `merge_tail!`'s `hecks_eras:domain` — plain writes serialize against
|
|
73
|
+
# each other only, never against a mint, and ordinal order equals
|
|
74
|
+
# commit order for them.
|
|
68
75
|
#
|
|
69
|
-
#
|
|
70
|
-
# narrow window before a mint's fence-move commits is the
|
|
76
|
+
# **The other part is deliberately open**. A stale-era write during the
|
|
77
|
+
# narrow window before a mint's fence-move commits is the same race by
|
|
71
78
|
# a different name — and closing it would mean a plain write
|
|
72
79
|
# serializing against a mint, which is exactly the guarantee
|
|
73
|
-
# `
|
|
74
|
-
# era
|
|
75
|
-
# residual `diverged_count`/`merge_tail!`
|
|
76
|
-
# something a plain write should ever block
|
|
80
|
+
# `spec/adapters/driven/postgres_era/lineage_spec.rb`'s "an old
|
|
81
|
+
# checkout keeps writing its own era through a mint" pins the absence
|
|
82
|
+
# of. That race stays the residual `diverged_count`/`merge_tail!`
|
|
83
|
+
# exist to reconcile, not something a plain write should ever block
|
|
84
|
+
# for.
|
|
85
|
+
#
|
|
86
|
+
# ## File layout
|
|
77
87
|
#
|
|
78
88
|
# One concern per file under lineage/: provisioning (DDL and the
|
|
79
89
|
# RLS posture), era_store (the hecks_eras rows and their integrity),
|
|
@@ -96,33 +106,118 @@ module Hecks
|
|
|
96
106
|
|
|
97
107
|
JOURNAL_COLUMNS = "ordinal, era, aggregate, aggregate_id, operation, state, mirrors".freeze
|
|
98
108
|
|
|
109
|
+
# Postgres's own NAMEDATALEN limit: an identifier over 63 bytes is
|
|
110
|
+
# silently truncated, never refused — so two different overlong
|
|
111
|
+
# names that happen to share their first 63 bytes would collide
|
|
112
|
+
# again, at a longer length, the exact same failure mode this
|
|
113
|
+
# whole file exists to close for `storage_name` alone. Domain-
|
|
114
|
+
# qualifying every name below (`qualified_name`, private) makes
|
|
115
|
+
# that readily reachable: a long domain name stacked onto a long
|
|
116
|
+
# aggregate name. A constant, not private — Ruby constants are
|
|
117
|
+
# never actually scoped by `private`
|
|
118
|
+
# (Lint/UselessConstantScoping), so this stays above it.
|
|
119
|
+
POSTGRES_IDENTIFIER_LIMIT = 63
|
|
120
|
+
|
|
99
121
|
attr_reader :db, :domain, :formerly_known_as
|
|
100
122
|
|
|
123
|
+
# @param db [PG::Connection] open connection to the database holding the domain's journal
|
|
124
|
+
# @param domain [String, Symbol] the domain's declared name; every relation name and
|
|
125
|
+
# advisory-lock key derives from it
|
|
126
|
+
# @param formerly_known_as [String, Symbol, nil] the domain's prior name, which makes
|
|
127
|
+
# `ensure_base!` rename its relations and rows first; nil when there is no prior name
|
|
101
128
|
def initialize(db, domain, formerly_known_as: nil)
|
|
102
129
|
@db = db
|
|
103
130
|
@domain = domain.to_s
|
|
104
131
|
@formerly_known_as = formerly_known_as&.to_s
|
|
105
132
|
end
|
|
106
133
|
|
|
134
|
+
# Names the domain's one journal table, the partitioned parent of every era's rows.
|
|
135
|
+
#
|
|
136
|
+
# @return [String] unquoted relation name, `hecks_journal_` plus the snake-cased domain
|
|
107
137
|
def journal = "hecks_journal_#{Naming.snake(@domain)}"
|
|
138
|
+
|
|
139
|
+
# Quotes the journal's name for direct interpolation into SQL.
|
|
140
|
+
#
|
|
141
|
+
# @return [String] `journal` as a double-quoted Postgres identifier
|
|
108
142
|
def quoted_journal = quote(journal)
|
|
143
|
+
|
|
144
|
+
# Names the sequence that assigns journal ordinals across every era's partition.
|
|
145
|
+
#
|
|
146
|
+
# @return [String] unquoted sequence name, `journal` plus `_ordinal`
|
|
109
147
|
def sequence = "#{journal}_ordinal"
|
|
148
|
+
|
|
149
|
+
# Names the journal partition that holds one era's rows.
|
|
150
|
+
#
|
|
151
|
+
# @param era [Integer] the era's ordinal, 1-based
|
|
152
|
+
# @return [String] unquoted relation name, `journal` plus `_era_` and the ordinal
|
|
110
153
|
def partition(era) = "#{journal}_era_#{era}"
|
|
111
|
-
|
|
154
|
+
|
|
155
|
+
# Names the view an aggregate's current state is read through.
|
|
156
|
+
#
|
|
157
|
+
# Domain-qualified, the same way `journal` is — see
|
|
158
|
+
# `qualified_name`'s own comment and docs/decisions/0059. Unqualified,
|
|
159
|
+
# two different domains bound to PostgresEra against the same
|
|
160
|
+
# database, each declaring an aggregate whose own name snake_cases
|
|
161
|
+
# to the same storage_name (found live: two unrelated "Note"
|
|
162
|
+
# aggregates), derive the exact same
|
|
163
|
+
# `note_head`/`note_head_snapshot_1` physical relations — every
|
|
164
|
+
# boot of the second domain silently clobbers the first's
|
|
165
|
+
# already-compiled head view, `ensure_first_head!`'s own
|
|
166
|
+
# "belt-and-suspenders self-healing" being exactly the mechanism
|
|
167
|
+
# that does it (postgres_era.rb's own comment there).
|
|
168
|
+
#
|
|
169
|
+
# @param storage_name [String] the aggregate's snake-cased storage name
|
|
170
|
+
# @return [String] unquoted view name, at most `POSTGRES_IDENTIFIER_LIMIT` bytes
|
|
171
|
+
def head_view(storage_name) = qualified_name("#{storage_name}_head")
|
|
172
|
+
|
|
173
|
+
# Names the snapshot table that backs one aggregate's head within one era.
|
|
174
|
+
#
|
|
112
175
|
# The transactionally-upserted read cache behind head_view — one row
|
|
113
|
-
# per
|
|
114
|
-
# at. Scoped by
|
|
115
|
-
#
|
|
176
|
+
# per live id, keyed by id, carrying the ordinal it was last written
|
|
177
|
+
# at. Scoped by era, not just storage_name — an aggregate that
|
|
178
|
+
# isn't renamed across a mint keeps the same storage_name in both
|
|
116
179
|
# eras, so storage_name alone would have era N+1 sharing one
|
|
117
180
|
# physical table with era N: a freshly-minted era would inherit
|
|
118
181
|
# every pre-mint (and, worse, pre-rekey/pre-translation) row
|
|
119
182
|
# instead of starting empty. era-qualified naming is what
|
|
120
183
|
# `partition`/`matview` already do for exactly this reason.
|
|
121
|
-
|
|
122
|
-
|
|
184
|
+
#
|
|
185
|
+
# @param storage_name [String] the aggregate's snake-cased storage name
|
|
186
|
+
# @param era [Integer] ordinal of the era the snapshot belongs to
|
|
187
|
+
# @return [String] unquoted table name, at most `POSTGRES_IDENTIFIER_LIMIT` bytes
|
|
188
|
+
def head_snapshot(storage_name, era) = qualified_name("#{storage_name}_head_snapshot_#{era}")
|
|
189
|
+
|
|
190
|
+
# Names the materialized view holding an aggregate's translated ancestor tail for one era.
|
|
191
|
+
#
|
|
192
|
+
# @param storage_name [String] the aggregate's snake-cased storage name
|
|
193
|
+
# @param era [Integer] ordinal of the era the view was compiled for
|
|
194
|
+
# @param label [String] the era's minted label, a prefix of its shape hash
|
|
195
|
+
# @return [String] unquoted matview name, at most `POSTGRES_IDENTIFIER_LIMIT` bytes
|
|
196
|
+
def matview(storage_name, era, label) = qualified_name("#{storage_name}_lineage_#{era}_#{label}")
|
|
123
197
|
|
|
124
198
|
private
|
|
125
199
|
|
|
200
|
+
# `@domain`, snake-cased and folded onto `suffix` — human-readable
|
|
201
|
+
# in the ordinary case (every one of these names gets read
|
|
202
|
+
# directly at a psql prompt during a live incident — see
|
|
203
|
+
# docs/decisions/0059's own verification section), degrading to a
|
|
204
|
+
# hashed, truncated form only once the readable form would
|
|
205
|
+
# actually risk exceeding `POSTGRES_IDENTIFIER_LIMIT`. The same
|
|
206
|
+
# trade `Runtime::StorageShape.mint_label` (a bare hash-prefix, no
|
|
207
|
+
# attempt at readability at all — a mint label is never meant to
|
|
208
|
+
# be legible on its own) and `FieldCache#field_cache` (fully
|
|
209
|
+
# hashed, for the same reason) already make elsewhere in this
|
|
210
|
+
# adapter — this one keeps more of the readable form than either,
|
|
211
|
+
# since unlike a mint label or a field-cache table, these names
|
|
212
|
+
# are the ones an operator reads and types by hand.
|
|
213
|
+
def qualified_name(suffix)
|
|
214
|
+
full = "#{Naming.snake(@domain)}_#{suffix}"
|
|
215
|
+
return full if full.bytesize <= POSTGRES_IDENTIFIER_LIMIT
|
|
216
|
+
|
|
217
|
+
digest = Digest::SHA256.hexdigest(full)[0, 8]
|
|
218
|
+
"#{full.byteslice(0, POSTGRES_IDENTIFIER_LIMIT - digest.bytesize - 1)}_#{digest}"
|
|
219
|
+
end
|
|
220
|
+
|
|
126
221
|
def quote(name) = PG::Connection.quote_ident(name.to_s)
|
|
127
222
|
|
|
128
223
|
def text_literal(text) = "'#{text.to_s.gsub("'", "''")}'"
|
|
@@ -10,12 +10,26 @@ module Hecks
|
|
|
10
10
|
module LineageManager
|
|
11
11
|
# What a mint must prove before it may happen: the edge covers
|
|
12
12
|
# the whole diff, no identity path was re-keyed, and the audit's
|
|
13
|
-
# first two layers pass over the
|
|
13
|
+
# first two layers pass over the live compiled chain.
|
|
14
14
|
module CoverageCheck
|
|
15
|
-
#
|
|
15
|
+
# Refuses a mint whose translation edge leaves any part of the shape change
|
|
16
|
+
# unexplained.
|
|
17
|
+
#
|
|
18
|
+
# Layer 1 against this edge specifically: every vanished or
|
|
16
19
|
# retyped path in the held→current diff must be explained, and
|
|
17
20
|
# every held aggregate must still be claimed. The refusal is
|
|
18
21
|
# EraGuard's own, byte for byte.
|
|
22
|
+
#
|
|
23
|
+
# @param registry [Runtime::Registry] the registry whose loaded translations may
|
|
24
|
+
# claim a held aggregate through `was:` or `retired`
|
|
25
|
+
# @param bluebook [Bluebook::Chapter] the domain as currently declared
|
|
26
|
+
# @param held_bluebook [Bluebook::Chapter] the domain as the latest held era's
|
|
27
|
+
# text declares it (a shadow parse)
|
|
28
|
+
# @param edge [Bluebook::Translation] the one edge leaving the latest held era
|
|
29
|
+
# @return [void]
|
|
30
|
+
# @raise [Runtime::WiringError] if an identity path changed without a `rekey`
|
|
31
|
+
# rule, a vanished or retyped path has no rule, a new required attribute has
|
|
32
|
+
# nothing to fill it, or a held aggregate is neither renamed nor retired
|
|
19
33
|
def check_coverage!(registry, bluebook, held_bluebook, edge)
|
|
20
34
|
bluebook.aggregates.each do |aggregate|
|
|
21
35
|
rules = Ports::Persistence::Lineage.from_declared(edge.for_aggregate(aggregate.name), aggregate.name)
|
|
@@ -32,15 +46,28 @@ module Hecks
|
|
|
32
46
|
Runtime::EraGuard.check_vanished_aggregates!(registry, bluebook, held_bluebook)
|
|
33
47
|
end
|
|
34
48
|
|
|
35
|
-
#
|
|
49
|
+
# Refuses a mint that changes an aggregate's identity paths without declaring
|
|
50
|
+
# a `rekey`.
|
|
51
|
+
#
|
|
52
|
+
# An identity-path change is a re-keying, not an ordinary
|
|
36
53
|
# translation — stored ids were fixed at write time under the old
|
|
37
|
-
# key, so this refuses
|
|
54
|
+
# key, so this refuses unless the edge declares a `rekey` for
|
|
38
55
|
# this aggregate covering exactly that. `rules.rekey?` (see
|
|
39
56
|
# `Ports::Persistence::Lineage`'s own comment) is the single
|
|
40
57
|
# source of truth every consumer of this fact asks — this is not
|
|
41
58
|
# a second, independent check of `declared.rekeys`.
|
|
59
|
+
#
|
|
60
|
+
# @param bluebook [Bluebook::Chapter] the domain, named in the refusal
|
|
61
|
+
# @param aggregate [Bluebook::Aggregate] the aggregate as currently declared
|
|
62
|
+
# @param held_aggregate [Bluebook::Aggregate] the same aggregate as the held
|
|
63
|
+
# era's text declares it
|
|
64
|
+
# @param rules [Ports::Persistence::Lineage, nil] the edge's rules for this
|
|
65
|
+
# aggregate; nil when the edge declares none, which cannot excuse a change
|
|
66
|
+
# @return [nil] when the identity paths are unchanged or a `rekey` covers them
|
|
67
|
+
# @raise [Runtime::WiringError] if the identity paths differ and no `rekey` rule
|
|
68
|
+
# is declared
|
|
42
69
|
def check_identity_unchanged!(bluebook, aggregate, held_aggregate, rules)
|
|
43
|
-
# The
|
|
70
|
+
# The full declared path lists, in declaration order — never the
|
|
44
71
|
# single-head shortcut, which is nil for every composite identity
|
|
45
72
|
# and so would let two different composites compare as unchanged.
|
|
46
73
|
return if held_aggregate.identity_paths == aggregate.identity_paths
|
|
@@ -56,10 +83,28 @@ module Hecks
|
|
|
56
83
|
"data explicitly"
|
|
57
84
|
end
|
|
58
85
|
|
|
59
|
-
#
|
|
86
|
+
# Previews every aggregate's translated head through the pending chain and
|
|
87
|
+
# refuses the mint if the audit finds a violation.
|
|
88
|
+
#
|
|
89
|
+
# Layers 1 and 2 of the audit, over the live compiled chain —
|
|
60
90
|
# before anything is minted, so a refusal leaves no half-born
|
|
61
91
|
# era. (A convert meeting an unmapped value raises inside the
|
|
62
92
|
# preview query itself: same rollback-shaped outcome.)
|
|
93
|
+
#
|
|
94
|
+
# @param bluebook [Bluebook::Chapter] the domain as currently declared
|
|
95
|
+
# @param lineage [Adapters::PostgresEra::Lineage] the domain's lineage, on an open
|
|
96
|
+
# connection
|
|
97
|
+
# @param chain [Array<Hash{Symbol => Bluebook::Translation}>] the full edge chain
|
|
98
|
+
# ending in `edge`, as `edge_chain` returns it
|
|
99
|
+
# @param ordinal [Integer] the ordinal of the era about to be minted
|
|
100
|
+
# @param edge [Bluebook::Translation] the pending edge, whose per-aggregate rules
|
|
101
|
+
# the audit checks
|
|
102
|
+
# @return [nil] when no aggregate reports a violation
|
|
103
|
+
# @raise [Runtime::WiringError] if Postgres refuses the translated preview query
|
|
104
|
+
# (a `convert` meeting an unmapped value, for one), or the audit reports any
|
|
105
|
+
# violation; the message lists them all
|
|
106
|
+
# @raise [PG::Error] if Postgres refuses the "before" query; only the translated
|
|
107
|
+
# preview is rescued into a `Runtime::WiringError`
|
|
63
108
|
def audit!(bluebook, lineage, chain, ordinal, edge)
|
|
64
109
|
violations = []
|
|
65
110
|
bluebook.aggregates.each do |aggregate|
|