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
|
@@ -15,7 +15,7 @@ require_relative "../../../../runtime/registry"
|
|
|
15
15
|
module Hecks
|
|
16
16
|
module Adapters
|
|
17
17
|
# The enforcement-grade persistence adapter — and the only one that
|
|
18
|
-
# declares the
|
|
18
|
+
# declares the lineage capability: it may act on shape drift
|
|
19
19
|
# (translate, fork, merge) where every other adapter can only refuse
|
|
20
20
|
# toward it. Sibling to the plain `Postgres` adapter (postgres.rb),
|
|
21
21
|
# which is the same database with none of this machinery — pick
|
|
@@ -23,19 +23,22 @@ module Hecks
|
|
|
23
23
|
# change live. See docs/implemented/postgres-era-adapter-split-plan.md for why
|
|
24
24
|
# the two are split and what each one carries.
|
|
25
25
|
#
|
|
26
|
-
# Storage model
|
|
27
|
-
#
|
|
26
|
+
# ## Storage model
|
|
27
|
+
#
|
|
28
|
+
# See postgres_era/lineage.rb for the DDL.
|
|
29
|
+
#
|
|
30
|
+
# - One journal per domain, list-partitioned by era, one ordinal
|
|
28
31
|
# sequence spanning partitions. Appends go there; nothing updates
|
|
29
32
|
# or deletes a journal row (immutability by privilege — UPDATE and
|
|
30
33
|
# DELETE revoked; a deployment's app role connects as a non-owner).
|
|
31
|
-
# - Per aggregate, the
|
|
34
|
+
# - Per aggregate, the head is derived: era 1 reads a plain view
|
|
32
35
|
# (latest save per id); later eras read a view overlaying the
|
|
33
36
|
# materialized, translated ancestor tail with live current-era
|
|
34
37
|
# rows. `project` is therefore a no-op — old entries are never
|
|
35
38
|
# rewritten, and the head is never a table anything writes.
|
|
36
|
-
# - State is
|
|
39
|
+
# - State is one jsonb column. jsonb normalizes key order (and drops
|
|
37
40
|
# duplicate keys), so anything comparing stored state — the corpus
|
|
38
|
-
# history gate above all — must compare
|
|
41
|
+
# history gate above all — must compare canonicalized state, never
|
|
39
42
|
# raw bytes; `bin/canonicalise` deep-sorts keys, which is exactly
|
|
40
43
|
# why the gate survives this normalization.
|
|
41
44
|
# - Query pushdown is the shared SqlQueryBuilder: every declared
|
|
@@ -47,21 +50,31 @@ module Hecks
|
|
|
47
50
|
|
|
48
51
|
attr_reader :aggregate
|
|
49
52
|
|
|
53
|
+
# Names the optional persistence capabilities this adapter implements natively.
|
|
54
|
+
#
|
|
50
55
|
# `:cross_process_lock` tells `Interpreting#run_dispatch_order_with_isolation`
|
|
51
|
-
# (runtime/interpreting.rb) this repository can hold a
|
|
56
|
+
# (runtime/interpreting.rb) this repository can hold a real
|
|
52
57
|
# cross-process lock for the whole dispatch order itself, via
|
|
53
58
|
# `with_write_lock` below — so it should use that instead of the
|
|
54
59
|
# in-process `AggregateLock` `Mutex` every other non-CAS repository
|
|
55
60
|
# falls back to. See ADR 0036: that in-process Mutex is invisible
|
|
56
61
|
# to `rust/host` dispatching against the same PostgresEra-bound
|
|
57
62
|
# tables from a separate OS process.
|
|
63
|
+
#
|
|
64
|
+
# @return [Array<Symbol>] always `[:atomic_put, :cross_process_lock]`
|
|
58
65
|
def persistence_capabilities = %i[atomic_put cross_process_lock]
|
|
59
66
|
|
|
67
|
+
# Declares that this adapter can act on shape drift rather than only refuse.
|
|
68
|
+
#
|
|
60
69
|
# The capability idiom: only PostgresEra answers true, and only
|
|
61
70
|
# PostgresEra carries an era_check! for the boot gate to delegate to.
|
|
71
|
+
#
|
|
72
|
+
# @return [Boolean] always true
|
|
62
73
|
def self.lineage_capable? = true
|
|
63
74
|
|
|
64
|
-
#
|
|
75
|
+
# Declares that two tenant boots of this adapter keep their tables apart.
|
|
76
|
+
#
|
|
77
|
+
# **Tenant-capable** — see Runtime::TenantCheck's own header for the
|
|
65
78
|
# full reasoning. `connect_for`'s own `schema:` setting (the
|
|
66
79
|
# Storehouse shared-instance mechanism, already built, already
|
|
67
80
|
# proven for eras) is what keeps two tenant boots' tables apart:
|
|
@@ -70,8 +83,31 @@ module Hecks
|
|
|
70
83
|
# resolves into that boot's own schema, never another tenant's —
|
|
71
84
|
# proven for real, not assumed, by tenant_isolation_spec.rb, the
|
|
72
85
|
# same discipline lineage_capable? already holds itself to.
|
|
86
|
+
#
|
|
87
|
+
# @return [Boolean] always true
|
|
73
88
|
def self.tenant_capable? = true
|
|
74
89
|
|
|
90
|
+
# Resolves which era this boot is, minting the next one when the shape has drifted
|
|
91
|
+
# and a covering translation edge exists. The boot gate's entry point; the work is
|
|
92
|
+
# `LineageManager.check!` (see `LineageManager::EraResolver`).
|
|
93
|
+
#
|
|
94
|
+
# @param registry [Runtime::Registry] the booting registry; receives the resolved
|
|
95
|
+
# ordinal in `resolved_eras` and, for a superseded boot, `superseded_eras`
|
|
96
|
+
# @param bluebook [Bluebook::Chapter] the domain being booted
|
|
97
|
+
# @param current_text [String] the domain's bluebook source as it stands on disk
|
|
98
|
+
# @param settings [Hash{Symbol, String => Object}] the world's persistence settings
|
|
99
|
+
# for this binding; `database` is required, `schema`, `role` and
|
|
100
|
+
# `allow_superuser` are honored
|
|
101
|
+
# @param directory [String, nil] the domain's bluebook directory, where
|
|
102
|
+
# `HECKS_SCAFFOLD=1` writes a translation edge; nil disables scaffolding
|
|
103
|
+
# @return [Integer, nil] the ordinal of the era this boot minted, or nil when it
|
|
104
|
+
# minted nothing (first boot, quiet reboot, or a held-but-superseded shape)
|
|
105
|
+
# @raise [Runtime::WiringError] if the database cannot be reached, the connection
|
|
106
|
+
# is a superuser without `allow_superuser`, a held text fails its integrity
|
|
107
|
+
# check, or the shape drifted and the mint refuses (no edge, a stale or forked
|
|
108
|
+
# edge, an unapproved compute or rekey, uncovered drift, or a failed audit)
|
|
109
|
+
# @raise [Bluebook::DSL::Malformed] if a held era's text parses under neither the
|
|
110
|
+
# current nor the legacy grammar
|
|
75
111
|
def self.era_check!(registry:, bluebook:, current_text:, settings:, directory: nil)
|
|
76
112
|
LineageManager.check!(
|
|
77
113
|
registry: registry, bluebook: bluebook, current_text: current_text,
|
|
@@ -79,15 +115,23 @@ module Hecks
|
|
|
79
115
|
)
|
|
80
116
|
end
|
|
81
117
|
|
|
82
|
-
#
|
|
118
|
+
# Reads one setting from a world's settings hash under either key spelling.
|
|
119
|
+
#
|
|
120
|
+
# Both spellings of a setting are honored — a world's settings hash
|
|
83
121
|
# may arrive symbol-keyed (built straight in Ruby) or string-keyed
|
|
84
122
|
# (round-tripped through JSON), and a domain that names one is not
|
|
85
123
|
# obligated to also skip the other. `key?` decides which spelling
|
|
86
124
|
# actually exists, never `||` — `||` cannot tell a genuinely stored
|
|
87
125
|
# `false` apart from an absent key, and would silently prefer the
|
|
88
|
-
#
|
|
126
|
+
# other spelling (or `default`) instead of returning the real, held
|
|
89
127
|
# answer. See Hecks::QuerySpecification::FieldPath#read for the same
|
|
90
128
|
# discipline applied to stored state instead of settings.
|
|
129
|
+
#
|
|
130
|
+
# @param settings [Hash{Symbol, String => Object}] the settings hash to read
|
|
131
|
+
# @param key [Symbol, String] the setting name; tried as given, then as a String
|
|
132
|
+
# @param default [Object, nil] what to return when neither spelling is a key
|
|
133
|
+
# @return [Object, nil] the stored value, including a stored `false` or `nil`, or
|
|
134
|
+
# `default` when the key is absent under both spellings
|
|
91
135
|
def self.setting(settings, key, default: nil)
|
|
92
136
|
return settings[key] if settings.key?(key)
|
|
93
137
|
|
|
@@ -97,8 +141,19 @@ module Hecks
|
|
|
97
141
|
default
|
|
98
142
|
end
|
|
99
143
|
|
|
144
|
+
# Opens a connection to the database a world declares, creating and selecting the
|
|
145
|
+
# declared `schema` and silencing sub-warning notices. The caller owns the
|
|
146
|
+
# connection and closes it.
|
|
147
|
+
#
|
|
148
|
+
# @param name [String] the domain or aggregate name, used only in refusal messages
|
|
149
|
+
# @param settings [Hash{Symbol, String => Object}] the world's settings; `database`
|
|
150
|
+
# is a database name or a `postgres://`/`postgresql://` URL, `schema` is optional
|
|
151
|
+
# @return [PG::Connection] an open connection with `search_path` set when a schema
|
|
152
|
+
# is declared
|
|
153
|
+
# @raise [Runtime::WiringError] if the settings declare no `database`, or Postgres
|
|
154
|
+
# refuses the connection, the `CREATE SCHEMA` or either `SET`
|
|
100
155
|
def self.connect_for(name, settings)
|
|
101
|
-
#
|
|
156
|
+
# **Lazy, on purpose** — same reasoning as Sqlite's own initialize:
|
|
102
157
|
# a domain that never wires PostgresEra should never need the gem.
|
|
103
158
|
require "pg"
|
|
104
159
|
|
|
@@ -116,38 +171,38 @@ module Hecks
|
|
|
116
171
|
PG.connect(dbname: declared)
|
|
117
172
|
end
|
|
118
173
|
|
|
119
|
-
#
|
|
174
|
+
# **Shared-instance isolation**. A domain that declares `schema` is
|
|
120
175
|
# sharing its Postgres instance with other domains (the
|
|
121
176
|
# storehouse) — every unqualified table/view/function reference
|
|
122
177
|
# this adapter and its lineage classes ever construct resolves
|
|
123
178
|
# through search_path, so this one SET is what makes ALTER
|
|
124
179
|
# TABLE ... SET SCHEMA migrations transparent to the rest of the
|
|
125
180
|
# adapter. A domain with no `schema` setting keeps Postgres's
|
|
126
|
-
# own default search_path (public)
|
|
181
|
+
# own default search_path (public).
|
|
127
182
|
schema = setting(settings, :schema)
|
|
128
183
|
if schema.to_s != ""
|
|
129
|
-
#
|
|
130
|
-
# nobody has created yet
|
|
131
|
-
# creation attempt with Postgres's own "no schema has been
|
|
184
|
+
# **The schema itself, idempotently** — without this, a domain
|
|
185
|
+
# naming a `schema:` nobody has created yet fails on its first
|
|
186
|
+
# table-creation attempt with Postgres's own "no schema has been
|
|
132
187
|
# selected to create in", found live provisioning tenant_
|
|
133
|
-
# isolation_spec.rb's own multi-schema fixture by hand
|
|
134
|
-
#
|
|
188
|
+
# isolation_spec.rb's own multi-schema fixture by hand.
|
|
189
|
+
# `CREATE SCHEMA IF NOT EXISTS` is exactly the
|
|
135
190
|
# same self-healing idempotency this adapter's own table/era
|
|
136
191
|
# provisioning already holds itself to (see the comment right
|
|
137
192
|
# below on `client_min_messages`) — a schema that already
|
|
138
|
-
# exists is the
|
|
193
|
+
# exists is the ordinary case for every boot after the first,
|
|
139
194
|
# not news.
|
|
140
195
|
connection.exec("CREATE SCHEMA IF NOT EXISTS #{connection.quote_ident(schema)}")
|
|
141
196
|
connection.exec("SET search_path TO #{connection.quote_ident(schema)}")
|
|
142
197
|
end
|
|
143
198
|
|
|
144
|
-
#
|
|
199
|
+
# **Quiet on purpose**. Provisioning re-runs its own idempotent
|
|
145
200
|
# `CREATE ... IF NOT EXISTS` checks on every boot — a schema that
|
|
146
|
-
# already exists is the
|
|
147
|
-
# surfaces every one as a
|
|
201
|
+
# already exists is the ordinary case, not news, and Postgres
|
|
202
|
+
# surfaces every one as a notice by default. `bin/set-password`
|
|
148
203
|
# boots a real registry just to mint an Identity, and nobody
|
|
149
204
|
# setting a password needs to see a page of "relation ...
|
|
150
|
-
# already exists, skipping" to do it.
|
|
205
|
+
# already exists, skipping" to do it. Warning and above (real
|
|
151
206
|
# problems) still surface.
|
|
152
207
|
connection.exec("SET client_min_messages = warning")
|
|
153
208
|
connection
|
|
@@ -156,6 +211,17 @@ module Hecks
|
|
|
156
211
|
"cannot bind PostgresEra at #{declared} for #{name}: #{e.message.strip}"
|
|
157
212
|
end
|
|
158
213
|
|
|
214
|
+
# Connects, provisions the domain's journal, this aggregate's head and field
|
|
215
|
+
# caches, and the event, saga and outbox tables. Every step is idempotent.
|
|
216
|
+
#
|
|
217
|
+
# @param aggregate [Bluebook::Aggregate] the aggregate this repository persists
|
|
218
|
+
# @param settings [Hash{Symbol, String => Object}] the world's settings plus what
|
|
219
|
+
# `RepositoryFactory.build` merges in: `domain` (journal name), `era` (the
|
|
220
|
+
# resolved ordinal, nil to self-resolve to the newest) and `superseded_by` (the
|
|
221
|
+
# ordinal that superseded this boot's era, nil for a current-era boot)
|
|
222
|
+
# @param root [String, nil] the registry root every adapter is offered; unused here
|
|
223
|
+
# @raise [Runtime::WiringError] if the settings declare no `database`, the
|
|
224
|
+
# connection is refused, or a held era text fails its integrity check
|
|
159
225
|
def initialize(aggregate:, settings: {}, root: nil)
|
|
160
226
|
@aggregate = aggregate
|
|
161
227
|
@settings = settings
|
|
@@ -168,7 +234,7 @@ module Hecks
|
|
|
168
234
|
# for a directly-instantiated adapter (specs, consoles) that
|
|
169
235
|
# skips the factory.
|
|
170
236
|
#
|
|
171
|
-
# When the aggregate
|
|
237
|
+
# When the aggregate does have an owning chapter (`hecks_owner`
|
|
172
238
|
# set — true for any aggregate sealed through a real bluebook,
|
|
173
239
|
# even if this adapter itself was built by hand), default to the
|
|
174
240
|
# chapter's own declared PascalCase name. That's the same string
|
|
@@ -183,20 +249,20 @@ module Hecks
|
|
|
183
249
|
# `hecks_owner` is only ever stamped by chapter construction, see
|
|
184
250
|
# `traits.rb`'s `hecks_owner = self`) there is no chapter name to
|
|
185
251
|
# match Rust against in the first place, so fall back to the
|
|
186
|
-
# aggregate's own name
|
|
252
|
+
# aggregate's own name.
|
|
187
253
|
@domain = self.class.setting(
|
|
188
254
|
settings, :domain, default: aggregate.hecks_owner&.name || aggregate.storage_name
|
|
189
255
|
).to_s
|
|
190
256
|
@lineage = Lineage.new(@db, @domain)
|
|
191
257
|
@lineage.ensure_base!
|
|
192
|
-
# The era gate resolves which era this boot
|
|
258
|
+
# The era gate resolves which era this boot is (an old checkout
|
|
193
259
|
# boots a held-but-superseded era and keeps writing its own
|
|
194
260
|
# partition); a directly-instantiated adapter defaults to the
|
|
195
261
|
# newest.
|
|
196
262
|
#
|
|
197
|
-
#
|
|
263
|
+
# Not `self.class.setting(...)` here — RepositoryFactory#build
|
|
198
264
|
# always merges `era: registry.resolved_eras[domain]` into
|
|
199
|
-
# settings, so the key is genuinely
|
|
265
|
+
# settings, so the key is genuinely present (not absent) for
|
|
200
266
|
# any domain the era boot gate hasn't resolved yet (or that
|
|
201
267
|
# doesn't have one at all), just holding `nil`. `setting`'s own
|
|
202
268
|
# presence-over-truthiness discipline (correct for a field like
|
|
@@ -209,6 +275,20 @@ module Hecks
|
|
|
209
275
|
# promise.
|
|
210
276
|
@era = settings.key?(:era) ? settings[:era] : settings["era"]
|
|
211
277
|
@era ||= @lineage.current_era
|
|
278
|
+
# The in-process half of the era fence. `EraResolver.check!` sets
|
|
279
|
+
# `registry.superseded_eras[domain]` for a held-but-superseded
|
|
280
|
+
# boot only, and `RepositoryFactory.build` merges it in here as
|
|
281
|
+
# `superseded_by:` — so this is nil for every current-era boot and
|
|
282
|
+
# for a directly-instantiated adapter (specs, consoles), which,
|
|
283
|
+
# like `era:` above, self-resolves rather than being told. When
|
|
284
|
+
# set, `append`/`atomic_put` refuse before issuing an INSERT (see
|
|
285
|
+
# `refuse_superseded_write!`): the RLS fence already refuses the
|
|
286
|
+
# same write for an ordinary role, but a superuser walks through
|
|
287
|
+
# RLS (BUG#24), and this checkout's own knowledge that it is stale
|
|
288
|
+
# is the one guard no role attribute can void. Same coalescing as
|
|
289
|
+
# `era:` — the key is always present from the factory, holding nil
|
|
290
|
+
# for the ordinary case.
|
|
291
|
+
@superseded_by = settings.key?(:superseded_by) ? settings[:superseded_by] : settings["superseded_by"]
|
|
212
292
|
# Unconditional and idempotent, regardless of era — belt-and-
|
|
213
293
|
# suspenders self-healing (compile_head! already ensures this for
|
|
214
294
|
# a freshly-minted era's own name; ensure_first_head! for era 1's)
|
|
@@ -216,7 +296,7 @@ module Hecks
|
|
|
216
296
|
# CREATE TABLE IF NOT EXISTS nobody pays for twice.
|
|
217
297
|
@lineage.ensure_head_snapshot!(table, @era)
|
|
218
298
|
@lineage.ensure_first_head!(table) if @era == 1
|
|
219
|
-
#
|
|
299
|
+
# The read-cache side of the era workaround (Track C,
|
|
220
300
|
# docs/implemented/postgres-era-adapter-split-plan.md §3) — one row-cache
|
|
221
301
|
# table per `where`-field this aggregate's own declared queries
|
|
222
302
|
# (and its entities' own) actually use, derived automatically
|
|
@@ -231,8 +311,18 @@ module Hecks
|
|
|
231
311
|
create_outbox_table!
|
|
232
312
|
end
|
|
233
313
|
|
|
314
|
+
# Names this aggregate in storage: the journal's `aggregate` column value and the
|
|
315
|
+
# stem of its head view, head snapshot and field-cache names.
|
|
316
|
+
#
|
|
317
|
+
# @return [String] the aggregate's snake_case storage name
|
|
234
318
|
def table = @aggregate.storage_name
|
|
235
319
|
|
|
320
|
+
# Reads one record's current state through the lineage-aware head view, so a record
|
|
321
|
+
# written under an ancestor era comes back translated to the current shape.
|
|
322
|
+
#
|
|
323
|
+
# @param id [String, Object] the record's identity, compared as `id.to_s`
|
|
324
|
+
# @return [Runtime::Instance, nil] the stored record, or nil when the head holds no
|
|
325
|
+
# saved row for that id (never written, or deleted)
|
|
236
326
|
def find(id)
|
|
237
327
|
result = @db.exec_params(%(SELECT id, state FROM #{quoted_head} WHERE id = $1), [id.to_s])
|
|
238
328
|
return nil if result.ntuples.zero?
|
|
@@ -240,7 +330,9 @@ module Hecks
|
|
|
240
330
|
instance(result[0])
|
|
241
331
|
end
|
|
242
332
|
|
|
243
|
-
#
|
|
333
|
+
# Lists every current record, ordered by id unless the caller names a field.
|
|
334
|
+
#
|
|
335
|
+
# order_by is a runtime value, not framework-authored bluebook source
|
|
244
336
|
# like every other caller of order_expression — a query param off an
|
|
245
337
|
# HTTP request, in the console's case. Whitelisted against the
|
|
246
338
|
# aggregate's own real attributes (plus its lifecycle field) before
|
|
@@ -249,6 +341,13 @@ module Hecks
|
|
|
249
341
|
# attribute at parse time. Without this, an unknown field wouldn't
|
|
250
342
|
# error — query_expression degrades a nil attribute to a harmless
|
|
251
343
|
# no-op path — it would just silently sort by nothing.
|
|
344
|
+
#
|
|
345
|
+
# @param order_by [String, Symbol, nil] an attribute name, the lifecycle field, or a
|
|
346
|
+
# dotted path whose first segment is one of those; nil orders by id
|
|
347
|
+
# @param direction [Symbol, String] `desc` in any case sorts descending, anything
|
|
348
|
+
# else ascending; nulls sort first ascending and last descending
|
|
349
|
+
# @return [Array<Runtime::Instance>] every saved record, `[]` when there are none
|
|
350
|
+
# @raise [Runtime::WiringError] if `order_by` names no attribute of this aggregate
|
|
252
351
|
def all(order_by: nil, direction: :asc)
|
|
253
352
|
return @db.exec(%(SELECT id, state FROM #{quoted_head} ORDER BY id)).map { |row| instance(row) } unless order_by
|
|
254
353
|
|
|
@@ -262,9 +361,15 @@ module Hecks
|
|
|
262
361
|
@db.exec(%(SELECT id, state FROM #{quoted_head} ORDER BY #{order_clause(spec, nil)})).map { |row| instance(row) }
|
|
263
362
|
end
|
|
264
363
|
|
|
364
|
+
# Counts current records in SQL against the head view, without loading any state.
|
|
365
|
+
#
|
|
366
|
+
# @return [Integer] how many saved, undeleted records the head holds
|
|
265
367
|
def count = @db.exec(%(SELECT COUNT(*) FROM #{quoted_head}))[0]["count"].to_i
|
|
266
368
|
|
|
267
|
-
#
|
|
369
|
+
# Runs a declared query, looking candidate ids up in the field caches first when
|
|
370
|
+
# its `where` clauses allow it.
|
|
371
|
+
#
|
|
372
|
+
# The two-phase shortcut (Track C, docs/implemented/postgres-era-adapter-
|
|
268
373
|
# split-plan.md §3). `SqlQueryBuilder#query` (`super`, unmodified per
|
|
269
374
|
# principle 2) always runs correctly here — it filters against
|
|
270
375
|
# `head_view`, which is already the fully-reduced current state —
|
|
@@ -276,25 +381,36 @@ module Hecks
|
|
|
276
381
|
# ordinary comparator, not a null-vs-value special case — see
|
|
277
382
|
# `cache_eligible?`), skip the reduction: look candidate ids up in
|
|
278
383
|
# the cache table(s) first (cheap, indexed, no reduction involved),
|
|
279
|
-
# then read
|
|
280
|
-
#
|
|
281
|
-
# clause that
|
|
384
|
+
# then read only those ids' current state from `head_view` — safe
|
|
385
|
+
# through the reduction because `id` is its own partition key. Any
|
|
386
|
+
# clause that isn't cache-eligible (an uncached field, or a null
|
|
282
387
|
# comparison) is simply re-checked against `head_view` in the
|
|
283
388
|
# second phase, exactly as `super` would have checked it anyway —
|
|
284
|
-
# this can only ever
|
|
389
|
+
# this can only ever narrow what phase two has to look at, never
|
|
285
390
|
# change what a clause means.
|
|
286
391
|
#
|
|
287
|
-
#
|
|
392
|
+
# Falls back to `super` whenever no clause can be accelerated — a
|
|
288
393
|
# query with no `where` at all (order_by-only — no cache table
|
|
289
394
|
# exists for these, see field_cache.rb), a query whose only clauses
|
|
290
395
|
# target fields with no cache table, or a domain that has never
|
|
291
396
|
# minted a second era at all (`@field_caches` is never empty just
|
|
292
397
|
# because era 1 has no reduction to skip — the cache tables still
|
|
293
398
|
# exist and still accelerate era 1 the same way, but the fallback
|
|
294
|
-
# path is already just as cheap there since head_view
|
|
399
|
+
# path is already just as cheap there since head_view is the
|
|
295
400
|
# snapshot table verbatim for era 1; skipping straight to `super`
|
|
296
|
-
# in that case would be a valid
|
|
401
|
+
# in that case would be a valid future optimization, not attempted
|
|
297
402
|
# here to keep this one code path correct for every era uniformly).
|
|
403
|
+
#
|
|
404
|
+
# @param declared [Bluebook::Query] the declared query, or a delegator wrapping one
|
|
405
|
+
# (tenant scoping and reference hops fold extra `where` clauses in that way)
|
|
406
|
+
# @param args [Hash{Symbol => Object}] caller-supplied values for the clauses, limit
|
|
407
|
+
# and offset that name an argument
|
|
408
|
+
# @param context [Hash] execution context the query port passes to every adapter;
|
|
409
|
+
# unused here
|
|
410
|
+
# @return [Array<Runtime::Instance>] the matching records in declared order, or by
|
|
411
|
+
# id when the query declares none; `[]` when nothing matches
|
|
412
|
+
# @raise [ArgumentError] if a clause uses an operator this dialect cannot compile,
|
|
413
|
+
# or `contains` on a list of multi-field value objects
|
|
298
414
|
def query(declared, args = {}, context: {})
|
|
299
415
|
return super if @field_caches.empty? || declared.wheres.empty?
|
|
300
416
|
|
|
@@ -308,27 +424,41 @@ module Hecks
|
|
|
308
424
|
head_phase(declared, uncached, ids, args)
|
|
309
425
|
end
|
|
310
426
|
|
|
427
|
+
# Runs a block inside one transaction holding the domain's cross-process write lock.
|
|
428
|
+
#
|
|
311
429
|
# ADR 0036's actual fix — see `persistence_capabilities` above.
|
|
312
|
-
# Wraps the
|
|
430
|
+
# Wraps the whole dispatch order (hydrate through save), not just
|
|
313
431
|
# `append`'s own transaction below: `lock_writes!` has to be held
|
|
314
432
|
# before hydrate even starts, or two cross-process writers can
|
|
315
433
|
# both hydrate unlocked and race for the write, each blind to the
|
|
316
434
|
# other. `transaction` (via `include Adapters::PostgresOutbox`) is
|
|
317
435
|
# already re-entrant — `append`/`atomic_put`'s own inner
|
|
318
436
|
# `transaction do ... end`, deep inside the block below, joins
|
|
319
|
-
# this
|
|
437
|
+
# this same transaction instead of opening/committing its own, so
|
|
320
438
|
# the advisory lock stays held until this whole block returns.
|
|
439
|
+
#
|
|
440
|
+
# @yield the dispatch order to run under the lock; an exception rolls the
|
|
441
|
+
# transaction back
|
|
442
|
+
# @yieldreturn [Object] whatever the dispatch order produces
|
|
443
|
+
# @return [Object] the block's own value
|
|
444
|
+
# @raise [PG::ConnectionBad] if the connection dropped; the adapter reconnects first,
|
|
445
|
+
# then re-raises so the caller decides whether to retry
|
|
446
|
+
# @raise [Runtime::WiringError] if the connection dropped and the reconnect attempt
|
|
447
|
+
# (`connect_for`) is refused as well
|
|
321
448
|
def with_write_lock(&block) = transaction { lock_writes!; block.call } # rubocop:disable Style/Semicolon
|
|
322
449
|
|
|
323
|
-
#
|
|
450
|
+
# Appends one entry to the journal and brings the head snapshot and every field
|
|
451
|
+
# cache up to date, all in one transaction under the domain's write lock.
|
|
452
|
+
#
|
|
453
|
+
# The lock is held for the whole transaction, not just around the INSERT — the
|
|
324
454
|
# ordinal is assigned by the column's own `nextval()` default, inside
|
|
325
455
|
# this same statement, so the lock has to already be held before that
|
|
326
|
-
# default evaluates. A
|
|
327
|
-
# `hecks_eras:domain` : this serializes plain writes against
|
|
328
|
-
#
|
|
456
|
+
# default evaluates. A different key from `mint_era!`/`merge_tail!`'s
|
|
457
|
+
# `hecks_eras:domain` : this serializes plain writes against each
|
|
458
|
+
# other, never against a mint. See postgres/lineage.rb's own comment
|
|
329
459
|
# for why only that half of the race is closed.
|
|
330
460
|
# The journal insert and the snapshot upsert/delete happen in the
|
|
331
|
-
#
|
|
461
|
+
# same transaction — real ACID atomicity, not the append-then-
|
|
332
462
|
# project two-step a file-based adapter needs a crash-recovery
|
|
333
463
|
# replay for (see Heki). If this transaction commits, the snapshot
|
|
334
464
|
# is already exactly as current as the journal; if it doesn't,
|
|
@@ -340,7 +470,15 @@ module Hecks
|
|
|
340
470
|
# dispatch, `lock_writes!` here is a harmless re-acquire of the
|
|
341
471
|
# same already-held (per-session-reentrant) advisory lock; a bare
|
|
342
472
|
# `repository.save` outside a full dispatch still takes it fresh.
|
|
473
|
+
#
|
|
474
|
+
# @param entry [Ports::Persistence::Entry] the save or delete to journal; `state`
|
|
475
|
+
# is decoded domain state, encoded through `StateCodec` on the way in
|
|
476
|
+
# @return [Ports::Persistence::Entry] the same entry, unchanged
|
|
477
|
+
# @raise [Runtime::WiringError] if this boot's era has been superseded by a mint
|
|
478
|
+
# @raise [PG::Error] if Postgres refuses a statement, such as the era fence's
|
|
479
|
+
# row-level security rejecting the INSERT
|
|
343
480
|
def append(entry)
|
|
481
|
+
refuse_superseded_write!
|
|
344
482
|
transaction do
|
|
345
483
|
lock_writes!
|
|
346
484
|
append_and_project!(entry)
|
|
@@ -348,11 +486,44 @@ module Hecks
|
|
|
348
486
|
entry
|
|
349
487
|
end
|
|
350
488
|
|
|
489
|
+
# Refuses a write from a checkout whose era a later mint has superseded.
|
|
490
|
+
#
|
|
491
|
+
# Before the transaction, before the lock, before the INSERT — a
|
|
492
|
+
# superseded checkout takes nothing and touches nothing. Reads are
|
|
493
|
+
# deliberately untouched: `EraResolver.check!`'s own contract for an
|
|
494
|
+
# old checkout is "may keep booting and reading, but may not keep
|
|
495
|
+
# writing", and the head views it reads through are its own era's.
|
|
496
|
+
#
|
|
497
|
+
# @return [nil] when this boot's era is current
|
|
498
|
+
# @raise [Runtime::WiringError] if the boot gate marked this era superseded
|
|
499
|
+
# (`superseded_by` in the settings)
|
|
500
|
+
def refuse_superseded_write!
|
|
501
|
+
return unless @superseded_by
|
|
502
|
+
|
|
503
|
+
raise Runtime::WiringError,
|
|
504
|
+
"cannot write #{table} for #{@domain}: this checkout booted era #{@era}, which era " \
|
|
505
|
+
"#{@superseded_by} has superseded — its shape was replaced by a mint, and a write here would " \
|
|
506
|
+
"land in a partition no newer head reads. Reads still work; pull the current bluebook and " \
|
|
507
|
+
"reboot to write again."
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Saves an entry and reports whether it inserted or replaced, deciding that under the
|
|
511
|
+
# same lock that guards the write.
|
|
512
|
+
#
|
|
351
513
|
# Outcome detection, journal append and every derived projection share
|
|
352
|
-
# the
|
|
514
|
+
# the same transaction and domain write lock. The lineage-aware head
|
|
353
515
|
# determines whether this id is already visible; no repository `find`
|
|
354
516
|
# occurs before entering this adapter-native operation.
|
|
517
|
+
#
|
|
518
|
+
# @param entry [Ports::Persistence::Entry] the save to journal
|
|
519
|
+
# @param insert_only [Boolean] when true, an id the head already shows is left
|
|
520
|
+
# untouched and nothing is journaled
|
|
521
|
+
# @return [Symbol] `:inserted`, `:replaced`, or `:conflicted` when `insert_only` met
|
|
522
|
+
# an existing record
|
|
523
|
+
# @raise [Runtime::WiringError] if this boot's era has been superseded by a mint
|
|
524
|
+
# @raise [PG::Error] if Postgres refuses a statement
|
|
355
525
|
def atomic_put(entry, insert_only: false)
|
|
526
|
+
refuse_superseded_write!
|
|
356
527
|
status = nil
|
|
357
528
|
transaction do
|
|
358
529
|
lock_writes!
|
|
@@ -370,17 +541,31 @@ module Hecks
|
|
|
370
541
|
status
|
|
371
542
|
end
|
|
372
543
|
|
|
373
|
-
#
|
|
544
|
+
# Builds the instance an entry describes, writing nothing.
|
|
545
|
+
#
|
|
546
|
+
# The head is derived — projecting is reading, so there is nothing
|
|
374
547
|
# to write here. `append` above already keeps the snapshot the head
|
|
375
548
|
# view reads from current, transactionally. The instance is still
|
|
376
549
|
# built (and validated) so a save returns what every other adapter
|
|
377
550
|
# returns.
|
|
551
|
+
#
|
|
552
|
+
# @param entry [Ports::Persistence::Entry] a journaled save or delete
|
|
553
|
+
# @return [Runtime::Instance, nil] the saved record, or nil for a delete entry
|
|
554
|
+
# @raise [Runtime::WiringError] if the entry's state is still in its stored
|
|
555
|
+
# (undecoded) form; see `Ports::Persistence::CodecBoundary`
|
|
378
556
|
def project(entry)
|
|
379
557
|
return if entry.delete?
|
|
380
558
|
|
|
381
559
|
Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: entry.state)
|
|
382
560
|
end
|
|
383
561
|
|
|
562
|
+
# Reads this aggregate's whole journal, every era, in the order it was written.
|
|
563
|
+
# States come back decoded but untranslated: an ancestor era's row keeps the shape
|
|
564
|
+
# it was written in.
|
|
565
|
+
#
|
|
566
|
+
# @return [Array<Ports::Persistence::Entry>] saves and deletes by ascending ordinal;
|
|
567
|
+
# a delete's `state` is nil, `mirrors` is nil when none were recorded, and a row
|
|
568
|
+
# with no recorded operation reads as `"save"`; `[]` for an empty journal
|
|
384
569
|
def entries
|
|
385
570
|
@db.exec_params(
|
|
386
571
|
"SELECT aggregate_id, operation, state, mirrors FROM #{@lineage.quoted_journal} " \
|
|
@@ -391,16 +576,19 @@ module Hecks
|
|
|
391
576
|
Ports::Persistence::Entry.new(
|
|
392
577
|
operation: row["operation"] || "save",
|
|
393
578
|
id: row["aggregate_id"],
|
|
394
|
-
state: state
|
|
579
|
+
state: Ports::Persistence::StateCodec.decode(@aggregate, state),
|
|
395
580
|
mirrors: row["mirrors"] && JSON.parse(row["mirrors"])
|
|
396
581
|
)
|
|
397
582
|
end
|
|
398
583
|
end
|
|
399
584
|
|
|
585
|
+
# Deletes this aggregate's journal rows, refusing when row-level security silently
|
|
586
|
+
# turns the DELETE into a no-op.
|
|
587
|
+
#
|
|
400
588
|
# The journal carries FORCE ROW LEVEL SECURITY with exactly two
|
|
401
589
|
# policies — hecks_current_era's INSERT and hecks_read_all's
|
|
402
590
|
# SELECT (advance_era! above) — and no DELETE policy at all, for
|
|
403
|
-
# anyone. FORCE means even the table's own owner is fenced by
|
|
591
|
+
# anyone. `FORCE` means even the table's own owner is fenced by
|
|
404
592
|
# that (only an actual Postgres superuser or a role granted
|
|
405
593
|
# BYPASSRLS sits above it — see lineage.rb's own header), so a
|
|
406
594
|
# plain `DELETE ... WHERE aggregate = $1` from an ordinary
|
|
@@ -410,6 +598,14 @@ module Hecks
|
|
|
410
598
|
# tells "nothing to delete" apart from "RLS silently ate the
|
|
411
599
|
# delete" — the same row count, from the same statement, either
|
|
412
600
|
# way, with no separate query racing the DELETE for an answer.
|
|
601
|
+
#
|
|
602
|
+
# Only the journal is cleared: head snapshots, field caches, the `events` table and
|
|
603
|
+
# saga rows are left as they are.
|
|
604
|
+
#
|
|
605
|
+
# @return [Adapters::PostgresEra] this adapter
|
|
606
|
+
# @raise [Runtime::WiringError] if the journal held rows for this aggregate and the
|
|
607
|
+
# DELETE removed none, which means the connection is neither a superuser nor
|
|
608
|
+
# granted BYPASSRLS
|
|
413
609
|
def reset!
|
|
414
610
|
before = @db.exec_params(
|
|
415
611
|
"SELECT count(*) FROM #{@lineage.quoted_journal} WHERE aggregate = $1", [table]
|
|
@@ -425,18 +621,40 @@ module Hecks
|
|
|
425
621
|
self
|
|
426
622
|
end
|
|
427
623
|
|
|
624
|
+
# Journals an instance's current state as a save entry. The direct-adapter
|
|
625
|
+
# convenience for specs and consoles; a runtime saves through
|
|
626
|
+
# `Ports::Persistence::AppendOnly`, which builds the entry itself.
|
|
627
|
+
#
|
|
628
|
+
# @param instance [Runtime::Instance] the record to persist
|
|
629
|
+
# @return [Runtime::Instance] a fresh instance built from the journaled state
|
|
630
|
+
# @raise [Runtime::WiringError] if this boot's era has been superseded by a mint
|
|
631
|
+
# @raise [PG::Error] if Postgres refuses a statement
|
|
428
632
|
def save(instance)
|
|
429
633
|
entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
|
|
430
634
|
append(entry)
|
|
431
635
|
project(entry)
|
|
432
636
|
end
|
|
433
637
|
|
|
638
|
+
# Journals a delete entry for an id and tombstones it in the head snapshot, without
|
|
639
|
+
# checking first that the record exists.
|
|
640
|
+
#
|
|
641
|
+
# @param id [String, Object] the record's identity, journaled as `id.to_s`
|
|
642
|
+
# @return [true] always, whether or not a record had that id
|
|
643
|
+
# @raise [Runtime::WiringError] if this boot's era has been superseded by a mint
|
|
644
|
+
# @raise [PG::Error] if Postgres refuses a statement
|
|
434
645
|
def delete(id)
|
|
435
646
|
entry = Ports::Persistence::Entry.new(operation: "delete", id: id.to_s, state: nil)
|
|
436
647
|
append(entry)
|
|
437
648
|
true
|
|
438
649
|
end
|
|
439
650
|
|
|
651
|
+
# Stores one emitted event durably in the `events` table, which every aggregate
|
|
652
|
+
# bound to this database and schema shares.
|
|
653
|
+
#
|
|
654
|
+
# @param event [Runtime::Event] the event to record; `payload` is stored as JSON,
|
|
655
|
+
# `occurred_at` as text (an ISO 8601 UTC string, or nil), and `correlation` is
|
|
656
|
+
# not stored
|
|
657
|
+
# @return [PG::Result] the INSERT's result, which carries no rows
|
|
440
658
|
def record_event(event)
|
|
441
659
|
@db.exec_params(
|
|
442
660
|
"INSERT INTO events (name, aggregate, aggregate_id, payload, occurred_at) VALUES ($1, $2, $3, $4, $5)",
|
|
@@ -444,6 +662,11 @@ module Hecks
|
|
|
444
662
|
)
|
|
445
663
|
end
|
|
446
664
|
|
|
665
|
+
# Reads every recorded event in the order it was recorded. The `events` table is
|
|
666
|
+
# shared, so this is not limited to this adapter's aggregate.
|
|
667
|
+
#
|
|
668
|
+
# @return [Array<Runtime::Event>] events with symbol-keyed `payload`, `occurred_at`
|
|
669
|
+
# as stored text and `correlation` nil; `[]` when none are recorded
|
|
447
670
|
def events
|
|
448
671
|
@db.exec("SELECT * FROM events ORDER BY id").map do |row|
|
|
449
672
|
Runtime::Event.new(
|
|
@@ -456,16 +679,28 @@ module Hecks
|
|
|
456
679
|
end
|
|
457
680
|
end
|
|
458
681
|
|
|
459
|
-
# ── the
|
|
682
|
+
# ── the optional saga-persistence capability (Ports::Persistence's
|
|
460
683
|
# own three-method shape, §2) — one row per (domain, process_manager,
|
|
461
684
|
# correlation), `domain` kept as an explicit column even under
|
|
462
685
|
# schema isolation so two domains sharing one schema (neither
|
|
463
686
|
# declares its own `schema`) still isolate correctly, matching
|
|
464
687
|
# `hecks_eras`' own precedent (postgres/lineage/provisioning.rb).
|
|
465
688
|
# No advisory lock of its own: every call here already runs inside
|
|
466
|
-
# `SagaInterpreter`'s own mutex (§7) serializing
|
|
467
|
-
# and gets the
|
|
689
|
+
# `SagaInterpreter`'s own mutex (§7) serializing in-process writers,
|
|
690
|
+
# and gets the same cross-process safety an aggregate's own writes
|
|
468
691
|
# get from this adapter — no better, no worse.
|
|
692
|
+
|
|
693
|
+
# Checkpoints one saga instance, replacing the row for the same
|
|
694
|
+
# (domain, process manager, correlation) if one exists.
|
|
695
|
+
#
|
|
696
|
+
# @param process_manager [String, Symbol] the process manager's name
|
|
697
|
+
# @param correlation [String, Object] the instance's correlation value, stored as
|
|
698
|
+
# `correlation.to_s`
|
|
699
|
+
# @param state [String, Symbol] the saga's current state name
|
|
700
|
+
# @param memory [Hash] the saga's memory, stored as JSON
|
|
701
|
+
# @param completed_compensations [Array] the ledger of compensable legs already
|
|
702
|
+
# completed, stored as JSON; `[]` when there are none
|
|
703
|
+
# @return [PG::Result] the upsert's result, which carries no rows
|
|
469
704
|
def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
|
|
470
705
|
@db.exec_params(
|
|
471
706
|
"INSERT INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, completed_compensations) " \
|
|
@@ -478,6 +713,12 @@ module Hecks
|
|
|
478
713
|
)
|
|
479
714
|
end
|
|
480
715
|
|
|
716
|
+
# Removes a finished saga instance's checkpoint; a no-op when no such row exists.
|
|
717
|
+
#
|
|
718
|
+
# @param process_manager [String, Symbol] the process manager's name
|
|
719
|
+
# @param correlation [String, Object] the instance's correlation value, matched as
|
|
720
|
+
# `correlation.to_s`
|
|
721
|
+
# @return [PG::Result] the DELETE's result, which carries no rows
|
|
481
722
|
def delete_saga(process_manager:, correlation:)
|
|
482
723
|
@db.exec_params(
|
|
483
724
|
"DELETE FROM hecks_saga_instances WHERE domain = $1 AND process_manager = $2 AND correlation = $3",
|
|
@@ -485,6 +726,18 @@ module Hecks
|
|
|
485
726
|
)
|
|
486
727
|
end
|
|
487
728
|
|
|
729
|
+
# Yields every saga checkpoint stored for this domain, so a booting registry can
|
|
730
|
+
# rehydrate its in-flight sagas.
|
|
731
|
+
#
|
|
732
|
+
# @yieldparam process_manager [String] the process manager's name
|
|
733
|
+
# @yieldparam correlation [String] the instance's correlation value
|
|
734
|
+
# @yieldparam state [String] the saga's state name
|
|
735
|
+
# @yieldparam memory [Hash{Symbol => Object}] the saga's memory, keys symbolized at
|
|
736
|
+
# every depth
|
|
737
|
+
# @yieldparam completed_compensations [Array] the completed-compensation ledger,
|
|
738
|
+
# `[]` when the row stores none
|
|
739
|
+
# @return [Enumerator, PG::Result] an enumerator over the same five values when no
|
|
740
|
+
# block is given; otherwise the query result, which callers ignore
|
|
488
741
|
def each_saga
|
|
489
742
|
return enum_for(:each_saga) unless block_given?
|
|
490
743
|
|
|
@@ -509,7 +762,7 @@ module Hecks
|
|
|
509
762
|
end
|
|
510
763
|
|
|
511
764
|
def append_and_project!(entry)
|
|
512
|
-
state_json = entry.state && JSON.generate(entry.state)
|
|
765
|
+
state_json = entry.state && JSON.generate(Ports::Persistence::StateCodec.encode(@aggregate, entry.state))
|
|
513
766
|
ordinal = @db.exec_params(
|
|
514
767
|
"INSERT INTO #{@lineage.quoted_journal} (era, aggregate, aggregate_id, operation, state, mirrors) " \
|
|
515
768
|
"VALUES ($1, $2, $3, $4, $5, $6) RETURNING ordinal",
|
|
@@ -524,7 +777,7 @@ module Hecks
|
|
|
524
777
|
"state = EXCLUDED.state WHERE #{quoted_head_snapshot}.ordinal < EXCLUDED.ordinal",
|
|
525
778
|
[entry.id, ordinal, state_json]
|
|
526
779
|
)
|
|
527
|
-
#
|
|
780
|
+
# **Same transaction, same ordinal** — every field cache stays
|
|
528
781
|
# exactly as current as the snapshot it's derived from, for
|
|
529
782
|
# the identical reason `postgres_era.rb`'s own header comment
|
|
530
783
|
# gives for the journal/snapshot pair: if this transaction
|
|
@@ -534,20 +787,20 @@ module Hecks
|
|
|
534
787
|
@lineage.upsert_field_cache_row!(cache_table, entry.id, ordinal, state_json, query_expression(field))
|
|
535
788
|
end
|
|
536
789
|
else
|
|
537
|
-
# A
|
|
538
|
-
# 10-main-bug-audit.md). `DELETE FROM head_snapshot`
|
|
539
|
-
#
|
|
790
|
+
# A tombstone row, not a bare DELETE — H3 (docs/audits/2026-08-
|
|
791
|
+
# 10-main-bug-audit.md). A plain `DELETE FROM head_snapshot` is
|
|
792
|
+
# correct in isolation but wrong
|
|
540
793
|
# once an ancestor era is in the picture: for a record carried
|
|
541
|
-
# into this era from an ancestor, removing this era's row
|
|
542
|
-
#
|
|
794
|
+
# into this era from an ancestor, removing this era's row leaves
|
|
795
|
+
# nothing on the current-era side of `compile_head!`'s union to
|
|
543
796
|
# outrank the ancestor matview's own (still-present, still
|
|
544
|
-
# `save`) row, so `DISTINCT ON`
|
|
545
|
-
# the "deleted" record
|
|
797
|
+
# `save`) row, so `DISTINCT ON` picks the ancestor's row and
|
|
798
|
+
# the "deleted" record keeps reading back forever. Upserting a
|
|
546
799
|
# tombstone (`operation = 'delete'`, `state` NULL) instead
|
|
547
|
-
# means this era always has
|
|
548
|
-
# id, exactly like a real re-save
|
|
800
|
+
# means this era always has its own newest-ordinal row for the
|
|
801
|
+
# id, exactly like a real re-save does ("re-saves are
|
|
549
802
|
# masked correctly" — the audit's own phrasing for why that
|
|
550
|
-
# half
|
|
803
|
+
# half never breaks) — it just carries `operation
|
|
551
804
|
# = 'delete'` instead of `'save'`, so `head_view`'s own `WHERE
|
|
552
805
|
# operation = 'save'` still correctly hides it. Ordinal-guarded
|
|
553
806
|
# the same as every other upsert here, so an out-of-order
|
|
@@ -607,11 +860,13 @@ module Hecks
|
|
|
607
860
|
Runtime::Instance.new(aggregate: @aggregate, id: row["id"], state: decode(row["state"]))
|
|
608
861
|
end
|
|
609
862
|
|
|
863
|
+
# Through the state codec (PR A3), after any SQL-side era
|
|
864
|
+
# translation has already run: the head view hands back the
|
|
865
|
+
# translated jsonb, and decoding it is the last step, the same as
|
|
866
|
+
# every other adapter's read. A never-seeded projected field is
|
|
867
|
+
# simply absent from the blob, and stays absent.
|
|
610
868
|
def decode(state_json)
|
|
611
|
-
|
|
612
|
-
# `symbolize_names:` decode produces — value-object members and
|
|
613
|
-
# list elements arrive symbol-keyed either way.
|
|
614
|
-
JSON.parse(state_json, symbolize_names: true)
|
|
869
|
+
Ports::Persistence::StateCodec.decode(@aggregate, JSON.parse(state_json))
|
|
615
870
|
end
|
|
616
871
|
|
|
617
872
|
def quote_ident(name) = PG::Connection.quote_ident(name.to_s)
|
|
@@ -638,9 +893,9 @@ module Hecks
|
|
|
638
893
|
"#{order_expression(order_by.field)} #{direction}#{nulls}, id #{direction}"
|
|
639
894
|
end
|
|
640
895
|
|
|
641
|
-
# One shared walk decides numericness at
|
|
642
|
-
#
|
|
643
|
-
# (pizza.price_cents.cents)
|
|
896
|
+
# One shared walk decides numericness at any depth — inspecting
|
|
897
|
+
# only the first nested segment would let a two-level path
|
|
898
|
+
# (pizza.price_cents.cents) skip the ::numeric cast and order
|
|
644
899
|
# as text: "900" above "1200".
|
|
645
900
|
def numeric_field?(field)
|
|
646
901
|
name, *path = field.to_s.split(".")
|
|
@@ -649,8 +904,8 @@ module Hecks
|
|
|
649
904
|
end
|
|
650
905
|
end
|
|
651
906
|
|
|
652
|
-
# ARRAY[...] of individually-escaped literals, never the hand-rolled
|
|
653
|
-
# '{a,b,c}' array-literal
|
|
907
|
+
# `ARRAY[...]` of individually-escaped literals, never the hand-rolled
|
|
908
|
+
# '{a,b,c}' array-literal syntax — a segment is a field or
|
|
654
909
|
# value-object member name, and while today's callers only ever
|
|
655
910
|
# pass schema-declared names, this method has no way to know
|
|
656
911
|
# that, and the '{...}' form has no escaping at all: a segment
|
|
@@ -658,7 +913,7 @@ module Hecks
|
|
|
658
913
|
# follows becomes live SQL. Measured, not assumed — a crafted
|
|
659
914
|
# field name of `x}' = '' OR $1::text = $1::text -- ` made a
|
|
660
915
|
# `where(secret: "public")` clause return every row regardless,
|
|
661
|
-
# against the
|
|
916
|
+
# against the '{...}' form; the `ARRAY[]` form below closes it, verified
|
|
662
917
|
# against the identical payload.
|
|
663
918
|
def jsonb_path(segments)
|
|
664
919
|
"state #>> ARRAY[#{segments.map { |segment| text_literal(segment) }.join(', ')}]::text[]"
|
|
@@ -668,7 +923,7 @@ module Hecks
|
|
|
668
923
|
|
|
669
924
|
# ── the field-cache read shortcut (Track C) ─────────────────────
|
|
670
925
|
|
|
671
|
-
#
|
|
926
|
+
# Every declared `where`-field this aggregate's own queries and its
|
|
672
927
|
# entities' own queries use, minus anything a cache table can't
|
|
673
928
|
# represent (see `cacheable_field?`) — never `order_by`-only
|
|
674
929
|
# fields, which never needed a cache in the first place (sorting
|
|
@@ -693,10 +948,10 @@ module Hecks
|
|
|
693
948
|
@aggregate.queries + @aggregate.entities.flat_map(&:queries)
|
|
694
949
|
end
|
|
695
950
|
|
|
696
|
-
#
|
|
951
|
+
# List-typed fields are excluded, same boundary the plain `Postgres`
|
|
697
952
|
# and `Sqlite`/`D1` adapters independently landed on for their own
|
|
698
953
|
# automatic indexing: `contains` means element membership, and a
|
|
699
|
-
# (id, ordinal,
|
|
954
|
+
# (id, ordinal, one value) cache row has nowhere to put more than
|
|
700
955
|
# one element. Everything else — a plain scalar, the lifecycle
|
|
701
956
|
# field, or a non-list value-object member path — reduces to
|
|
702
957
|
# exactly one comparable value per id, which is the one shape this
|
|
@@ -710,7 +965,7 @@ module Hecks
|
|
|
710
965
|
end
|
|
711
966
|
|
|
712
967
|
# A clause can be served by the cache when its field has a cache
|
|
713
|
-
# table
|
|
968
|
+
# table and it isn't the null-vs-value special case
|
|
714
969
|
# `QuerySpecification::Common::NullPolicy` intercepts before
|
|
715
970
|
# `where_clause` ever runs (see `query`'s own header comment) — a
|
|
716
971
|
# clause that fails either check simply flows to `head_phase`
|
|
@@ -721,11 +976,11 @@ module Hecks
|
|
|
721
976
|
value).nil?
|
|
722
977
|
end
|
|
723
978
|
|
|
724
|
-
#
|
|
979
|
+
# **Phase one** — candidate ids, no reduction touched. One SELECT per
|
|
725
980
|
# cached clause against its own narrow (id, ordinal, value) table,
|
|
726
981
|
# `INTERSECT`ed into the set that satisfies every cached clause at
|
|
727
982
|
# once. Reuses `where_clause` (SqlQueryBuilder, private, already
|
|
728
|
-
# mixed into this class)
|
|
983
|
+
# mixed into this class) unchanged against the cache table's own
|
|
729
984
|
# `value` column instead of a jsonb path expression — the exact
|
|
730
985
|
# same operator compilation (`eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`in`)
|
|
731
986
|
# a live query already gets against the real column, so a cached
|
|
@@ -741,12 +996,12 @@ module Hecks
|
|
|
741
996
|
@db.exec_params(clauses.join("\nINTERSECT\n"), binds).map { |row| row["id"] }
|
|
742
997
|
end
|
|
743
998
|
|
|
744
|
-
#
|
|
745
|
-
#
|
|
999
|
+
# **Phase two** — `head_view`, restricted to phase one's candidate ids
|
|
1000
|
+
# plus whatever clauses phase one couldn't accelerate, applied
|
|
746
1001
|
# exactly the way `SqlQueryBuilder#query` (`super`) already applies
|
|
747
1002
|
# every clause today: against the fully-reduced view, which is
|
|
748
1003
|
# already correct regardless of caching (see this file's own
|
|
749
|
-
# `query` comment — a cache is a
|
|
1004
|
+
# `query` comment — a cache is a speed shortcut, never a
|
|
750
1005
|
# correctness fix; head_view was always safe to filter directly,
|
|
751
1006
|
# just expensive to reduce in the first place). Duplicates a small
|
|
752
1007
|
# slice of `SqlQueryBuilder#query`'s own tail assembly (order_by/
|