hecks 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +5 -5
- data/lib/hecks/adapters/driven/claude_code.adapter +3 -0
- data/lib/hecks/adapters/driven/claude_code.rb +127 -0
- data/lib/hecks/adapters/driven/d1.adapter +12 -0
- data/lib/hecks/adapters/driven/d1.rb +427 -0
- data/lib/hecks/adapters/driven/folder.adapter +3 -0
- data/lib/hecks/adapters/driven/folder.rb +199 -0
- data/lib/hecks/adapters/driven/google_authentication.adapter +3 -0
- data/lib/hecks/adapters/driven/google_authentication.rb +100 -0
- data/lib/hecks/adapters/driven/governance_authorization.adapter +3 -0
- data/lib/hecks/adapters/driven/governance_authorization.rb +101 -0
- data/lib/hecks/adapters/driven/heki/journal.rb +56 -0
- data/lib/hecks/adapters/driven/heki/saga_store.rb +99 -0
- data/lib/hecks/adapters/driven/heki/snapshot.rb +65 -0
- data/lib/hecks/adapters/driven/heki.adapter +4 -0
- data/lib/hecks/adapters/driven/heki.rb +179 -0
- data/lib/hecks/adapters/driven/identity_registry.adapter +3 -0
- data/lib/hecks/adapters/driven/identity_registry.rb +26 -0
- data/lib/hecks/adapters/driven/in_memory_ordering.rb +51 -0
- data/lib/hecks/adapters/driven/lambda/client.rb +63 -0
- data/lib/hecks/adapters/driven/lambda.adapter +4 -0
- data/lib/hecks/adapters/driven/lambda.rb +145 -0
- data/lib/hecks/adapters/driven/memory.adapter +3 -0
- data/lib/hecks/adapters/driven/memory.rb +103 -0
- data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +3 -0
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +28 -0
- data/lib/hecks/adapters/driven/postgres/codec.rb +88 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +207 -0
- data/lib/hecks/adapters/driven/postgres.adapter +5 -0
- data/lib/hecks/adapters/driven/postgres.rb +438 -0
- data/lib/hecks/adapters/driven/postgres_era.adapter +17 -0
- data/lib/hecks/adapters/driven/prism.adapter +3 -0
- data/lib/hecks/adapters/driven/prism.rb +80 -0
- data/lib/hecks/adapters/driven/secure_random_identity.adapter +3 -0
- data/lib/hecks/adapters/driven/secure_random_identity.rb +14 -0
- data/lib/hecks/adapters/driven/sql_query_builder.rb +221 -0
- data/lib/hecks/adapters/driven/sqlite/codec.rb +80 -0
- data/lib/hecks/adapters/driven/sqlite/projection.rb +172 -0
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +192 -0
- data/lib/hecks/adapters/driven/sqlite.adapter +9 -0
- data/lib/hecks/adapters/driven/sqlite.rb +306 -0
- data/lib/hecks/adapters/driven/system_clock.adapter +3 -0
- data/lib/hecks/adapters/driven/system_clock.rb +14 -0
- data/lib/hecks/adapters/driven.rb +54 -0
- data/lib/hecks/adapters.rb +6 -0
- data/lib/hecks/behaviors/dsl.rb +125 -0
- data/lib/hecks/behaviors/expectations.rb +340 -0
- data/lib/hecks/behaviors/ir.rb +31 -0
- data/lib/hecks/behaviors/rspec.rb +42 -0
- data/lib/hecks/behaviors/runner.rb +96 -0
- data/lib/hecks/behaviors.rb +25 -0
- data/lib/hecks/bluebook/aggregate.rb +108 -0
- data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +134 -0
- data/lib/hecks/bluebook/assembly/build.rb +48 -0
- data/lib/hecks/bluebook/assembly/contract.rb +112 -0
- data/lib/hecks/bluebook/assembly/contracts.rb +438 -0
- data/lib/hecks/bluebook/assembly/marks.rb +228 -0
- data/lib/hecks/bluebook/assembly/specializer.rb +70 -0
- data/lib/hecks/bluebook/assembly.rb +91 -0
- data/lib/hecks/bluebook/attribute.rb +96 -0
- data/lib/hecks/bluebook/behaviour/aggregate.rb +83 -0
- data/lib/hecks/bluebook/behaviour/attribute.rb +27 -0
- data/lib/hecks/bluebook/behaviour/chapter.rb +72 -0
- data/lib/hecks/bluebook/behaviour/command.rb +116 -0
- data/lib/hecks/bluebook/behaviour/domain_port.rb +25 -0
- data/lib/hecks/bluebook/behaviour/entity.rb +59 -0
- data/lib/hecks/bluebook/behaviour/hexagon.rb +50 -0
- data/lib/hecks/bluebook/behaviour/lifecycle.rb +68 -0
- data/lib/hecks/bluebook/behaviour/policy.rb +52 -0
- data/lib/hecks/bluebook/behaviour/process_manager.rb +51 -0
- data/lib/hecks/bluebook/behaviour/query.rb +10 -0
- data/lib/hecks/bluebook/behaviour/read_model.rb +29 -0
- data/lib/hecks/bluebook/behaviour/traits.rb +81 -0
- data/lib/hecks/bluebook/behaviour/value_object.rb +33 -0
- data/lib/hecks/bluebook/chapter.rb +78 -0
- data/lib/hecks/bluebook/command.rb +124 -0
- data/lib/hecks/bluebook/domain_port.rb +102 -0
- data/lib/hecks/bluebook/dsl/adapter_builder.rb +34 -0
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +1018 -0
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +348 -0
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +71 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +1087 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +714 -0
- data/lib/hecks/bluebook/dsl/const_shim.rb +81 -0
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +79 -0
- data/lib/hecks/bluebook/dsl/entity_builder.rb +430 -0
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +366 -0
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +163 -0
- data/lib/hecks/bluebook/dsl/identity_declaration.rb +191 -0
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +44 -0
- data/lib/hecks/bluebook/dsl/malformed.rb +7 -0
- data/lib/hecks/bluebook/dsl/policy_builder.rb +135 -0
- data/lib/hecks/bluebook/dsl/port_builder.rb +39 -0
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +142 -0
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +306 -0
- data/lib/hecks/bluebook/dsl/query_builder.rb +113 -0
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +233 -0
- data/lib/hecks/bluebook/dsl/rule_reference.rb +173 -0
- data/lib/hecks/bluebook/dsl/translation_builder.rb +243 -0
- data/lib/hecks/bluebook/dsl/value_object_builder.rb +178 -0
- data/lib/hecks/bluebook/dsl/word_gate.rb +228 -0
- data/lib/hecks/bluebook/dsl/world_builder.rb +117 -0
- data/lib/hecks/bluebook/dsl.rb +45 -0
- data/lib/hecks/bluebook/entity.rb +103 -0
- data/lib/hecks/bluebook/expression/canonical_form.rb +123 -0
- data/lib/hecks/bluebook/expression/evaluator.rb +304 -0
- data/lib/hecks/bluebook/expression/projection.json +218 -0
- data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +233 -0
- data/lib/hecks/bluebook/expression/resolver.rb +781 -0
- data/lib/hecks/bluebook/expression.rb +13 -0
- data/lib/hecks/bluebook/hexagon.rb +60 -0
- data/lib/hecks/bluebook/lifecycle.rb +42 -0
- data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +54 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +621 -0
- data/lib/hecks/bluebook/meta_validator/plan.rb +332 -0
- data/lib/hecks/bluebook/meta_validator/port_judge.rb +51 -0
- data/lib/hecks/bluebook/meta_validator/readings.rb +360 -0
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +351 -0
- data/lib/hecks/bluebook/meta_validator/shapes.rb +266 -0
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +255 -0
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +138 -0
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +78 -0
- data/lib/hecks/bluebook/meta_validator.rb +542 -0
- data/lib/hecks/bluebook/model_check.rb +445 -0
- data/lib/hecks/bluebook/pattern_subset.rb +184 -0
- data/lib/hecks/bluebook/policy.rb +41 -0
- data/lib/hecks/bluebook/process_manager.rb +128 -0
- data/lib/hecks/bluebook/project_discovery.rb +30 -0
- data/lib/hecks/bluebook/project_loader.rb +40 -0
- data/lib/hecks/bluebook/project_register.rb +82 -0
- data/lib/hecks/bluebook/query.rb +61 -0
- data/lib/hecks/bluebook/read_model.rb +109 -0
- data/lib/hecks/bluebook/reference.rb +74 -0
- data/lib/hecks/bluebook/smoke_test.rb +166 -0
- data/lib/hecks/bluebook/synthesizer.rb +95 -0
- data/lib/hecks/bluebook/translation.rb +92 -0
- data/lib/hecks/bluebook/value_object.rb +66 -0
- data/lib/hecks/bluebook.rb +74 -0
- data/lib/hecks/codemod.rb +342 -0
- data/lib/hecks/construct.rb +71 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +219 -0
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +4 -0
- data/lib/hecks/deploy/oidc.json +18 -0
- data/lib/hecks/doc/reference.rb +410 -0
- data/lib/hecks/embryonaut_bluebook.rb +75 -0
- data/lib/hecks/facade/cli_door.rb +119 -0
- data/lib/hecks/facade/cli_runner.rb +190 -0
- data/lib/hecks/facade/command_request.rb +105 -0
- data/lib/hecks/facade/handle.rb +173 -0
- data/lib/hecks/facade/json_door.rb +166 -0
- data/lib/hecks/facade/surface/aggregate_door.rb +185 -0
- data/lib/hecks/facade/surface/chapter.rb +107 -0
- data/lib/hecks/facade/surface.rb +48 -0
- data/lib/hecks/facade.rb +44 -0
- data/lib/hecks/forms/app.rb +341 -0
- data/lib/hecks/forms/command_form_renderer.rb +113 -0
- data/lib/hecks/forms/examples/banking_console.bluebook +3 -0
- data/lib/hecks/forms/field_renderer.rb +177 -0
- data/lib/hecks/forms/field_shape.rb +232 -0
- data/lib/hecks/forms/html.rb +84 -0
- data/lib/hecks/forms/index_renderer.rb +35 -0
- data/lib/hecks/forms/page.rb +157 -0
- data/lib/hecks/forms/params.rb +161 -0
- data/lib/hecks/forms/port_argument.rb +46 -0
- data/lib/hecks/forms/query_form_renderer.rb +114 -0
- data/lib/hecks/forms/record_renderer.rb +119 -0
- data/lib/hecks/forms/record_table.rb +68 -0
- data/lib/hecks/forms/reference_options.rb +30 -0
- data/lib/hecks/forms/value_object_shape.rb +46 -0
- data/lib/hecks/forms.rb +54 -0
- data/lib/hecks/fqn.rb +94 -0
- data/lib/hecks/framework/bluebook/compliance.bluebook +1 -0
- data/lib/hecks/framework/bluebook/console_settings.bluebook +489 -0
- data/lib/hecks/framework/bluebook/framework.hecksagon +32 -0
- data/lib/hecks/framework/bluebook/governance.bluebook +145 -0
- data/lib/hecks/framework/bluebook/identity.bluebook +90 -0
- data/lib/hecks/framework/oidc.json +39 -0
- data/lib/hecks/framework.rb +90 -0
- data/lib/hecks/freezer.rb +67 -0
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
- data/lib/hecks/fuzzing/invalid_value_generator.rb +86 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +286 -0
- data/lib/hecks/fuzzing/properties.rb +1192 -0
- data/lib/hecks/fuzzing/replay.rb +668 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +93 -0
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +80 -0
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +96 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +250 -0
- data/lib/hecks/fuzzing/sequence_generator.rb +131 -0
- data/lib/hecks/fuzzing/value_generator.rb +200 -0
- data/lib/hecks/fuzzing.rb +12 -0
- data/lib/hecks/grammar/evolve.rb +324 -0
- data/lib/hecks/grammar/expression.bluebook +420 -0
- data/lib/hecks/grammar/expression_operators.json +1648 -0
- data/lib/hecks/grammar/grammar.hecksagon +20 -0
- data/lib/hecks/grammar/oidc.json +69 -0
- data/lib/hecks/grammar/translation.bluebook +203 -0
- data/lib/hecks/grammar.rb +178 -0
- data/lib/hecks/ir.rb +126 -0
- data/lib/hecks/language/adapter.bluebook +116 -0
- data/lib/hecks/language/bluebook/aggregate.bluebook +581 -0
- data/lib/hecks/language/bluebook/attaches/paging.bluebook +76 -0
- data/lib/hecks/language/bluebook/bluebook.bluebook +256 -0
- data/lib/hecks/language/bluebook/bluebook.hecksagon +24 -0
- data/lib/hecks/language/bluebook/command.bluebook +471 -0
- data/lib/hecks/language/bluebook/entity.bluebook +392 -0
- data/lib/hecks/language/bluebook/policy.bluebook +189 -0
- data/lib/hecks/language/bluebook/process_manager.bluebook +381 -0
- data/lib/hecks/language/bluebook/projection.bluebook +267 -0
- data/lib/hecks/language/bluebook/query.bluebook +245 -0
- data/lib/hecks/language/bluebook/shape.bluebook +292 -0
- data/lib/hecks/language/bluebook/syntax.bluebook +448 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +379 -0
- data/lib/hecks/language/hecksagon/adapter_binding.bluebook +51 -0
- data/lib/hecks/language/hecksagon/domain_port.bluebook +76 -0
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +131 -0
- data/lib/hecks/language/hecksagon/port_operation.bluebook +102 -0
- data/lib/hecks/language/oidc.json +333 -0
- data/lib/hecks/language/port.bluebook +120 -0
- data/lib/hecks/language/translation/translation.bluebook +110 -0
- data/lib/hecks/language/translation/translation_aggregate.bluebook +267 -0
- data/lib/hecks/language/world/wiring.bluebook +62 -0
- data/lib/hecks/language/world/world.bluebook +84 -0
- data/lib/hecks/literal.rb +125 -0
- data/lib/hecks/naming.rb +174 -0
- data/lib/hecks/ports/access_control.port +9 -0
- data/lib/hecks/ports/access_control.rb +62 -0
- data/lib/hecks/ports/agent/answers.rb +104 -0
- data/lib/hecks/ports/agent.port +8 -0
- data/lib/hecks/ports/agent.rb +167 -0
- data/lib/hecks/ports/authentication.port +6 -0
- data/lib/hecks/ports/authentication.rb +50 -0
- data/lib/hecks/ports/authorization.port +7 -0
- data/lib/hecks/ports/authorization.rb +62 -0
- data/lib/hecks/ports/clock.port +5 -0
- data/lib/hecks/ports/clock.rb +62 -0
- data/lib/hecks/ports/extraction.port +5 -0
- data/lib/hecks/ports/extraction.rb +37 -0
- data/lib/hecks/ports/identity_assignment.port +5 -0
- data/lib/hecks/ports/identity_assignment.rb +45 -0
- data/lib/hecks/ports/identity_generation.port +5 -0
- data/lib/hecks/ports/identity_generation.rb +49 -0
- data/lib/hecks/ports/identity_resolution.port +5 -0
- data/lib/hecks/ports/identity_resolution.rb +40 -0
- data/lib/hecks/ports/loading.port +4 -0
- data/lib/hecks/ports/loading.rb +13 -0
- data/lib/hecks/ports/persistence/append_only.rb +138 -0
- data/lib/hecks/ports/persistence/binding_policy.rb +56 -0
- data/lib/hecks/ports/persistence/execution.rb +23 -0
- data/lib/hecks/ports/persistence/null_saga_store.rb +25 -0
- data/lib/hecks/ports/persistence/plugin.rb +54 -0
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +169 -0
- data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +128 -0
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +161 -0
- data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +61 -0
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +304 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +171 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +190 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +478 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +166 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +314 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +168 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +170 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +134 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +137 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +89 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +85 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +43 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +132 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +74 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +758 -0
- data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +120 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +31 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +46 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +102 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +46 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +70 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +72 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +120 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +183 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +41 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +28 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +30 -0
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +11 -0
- data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
- data/lib/hecks/ports/persistence/remote_runtime.rb +42 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +30 -0
- data/lib/hecks/ports/persistence.port +4 -0
- data/lib/hecks/ports/persistence.rb +39 -0
- data/lib/hecks/ports/projection.port +4 -0
- data/lib/hecks/ports/projection.rb +96 -0
- data/lib/hecks/ports/query/in_memory.rb +60 -0
- data/lib/hecks/ports/query/ordering.rb +41 -0
- data/lib/hecks/ports/query.rb +36 -0
- data/lib/hecks/ports.rb +27 -0
- data/lib/hecks/projections/diagrams.rb +642 -0
- data/lib/hecks/projections/ir.rb +18 -0
- data/lib/hecks/projections/model/deviations.rb +98 -0
- data/lib/hecks/projections/model.rb +145 -0
- data/lib/hecks/projections/oidc.rb +110 -0
- data/lib/hecks/projections/parser_table.rb +159 -0
- data/lib/hecks/projections/reference.rb +38 -0
- data/lib/hecks/projections/shape.rb +44 -0
- data/lib/hecks/projections/statements.rb +110 -0
- data/lib/hecks/projections/vocabulary.rb +100 -0
- data/lib/hecks/projections.rb +32 -0
- data/lib/hecks/projector/cli_projector.rb +446 -0
- data/lib/hecks/projector/docs_projector.rb +321 -0
- data/lib/hecks/projector/exporter.rb +158 -0
- data/lib/hecks/projector/ir_projector.rb +18 -0
- data/lib/hecks/projector/narrate_projector.rb +243 -0
- data/lib/hecks/projector/target.rb +97 -0
- data/lib/hecks/projector.rb +186 -0
- data/lib/hecks/query_ir.rb +411 -0
- data/lib/hecks/query_specification/common/authorization_spec.rb +9 -0
- data/lib/hecks/query_specification/common/comparators.rb +25 -0
- data/lib/hecks/query_specification/common/comparison.rb +174 -0
- data/lib/hecks/query_specification/common/cursor_spec.rb +9 -0
- data/lib/hecks/query_specification/common/dsl.rb +58 -0
- data/lib/hecks/query_specification/common/inspection_spec.rb +9 -0
- data/lib/hecks/query_specification/common/limit_spec.rb +9 -0
- data/lib/hecks/query_specification/common/null_policy.rb +92 -0
- data/lib/hecks/query_specification/common/null_semantics.rb +11 -0
- data/lib/hecks/query_specification/common/offset_spec.rb +9 -0
- data/lib/hecks/query_specification/common/options.rb +32 -0
- data/lib/hecks/query_specification/common/order_by.rb +9 -0
- data/lib/hecks/query_specification/common/specification.rb +10 -0
- data/lib/hecks/query_specification/common/where_clause.rb +9 -0
- data/lib/hecks/query_specification/field_path.rb +107 -0
- data/lib/hecks/query_specification/hop_path.rb +132 -0
- data/lib/hecks/query_specification/read_model/specification.rb +18 -0
- data/lib/hecks/query_specification.rb +14 -0
- data/lib/hecks/rendering.rb +48 -0
- data/lib/hecks/router/namespace_installer.rb +157 -0
- data/lib/hecks/router.rb +70 -0
- data/lib/hecks/runtime/aggregate_lock.rb +45 -0
- data/lib/hecks/runtime/boot_gates.rb +41 -0
- data/lib/hecks/runtime/caller.rb +66 -0
- data/lib/hecks/runtime/capability_graph.rb +44 -0
- data/lib/hecks/runtime/command_interpreter/argument_gate.rb +135 -0
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +260 -0
- data/lib/hecks/runtime/command_interpreter.rb +497 -0
- data/lib/hecks/runtime/command_rules/admissibility.rb +357 -0
- data/lib/hecks/runtime/command_rules/arithmetic.rb +260 -0
- data/lib/hecks/runtime/command_rules/authorization.rb +65 -0
- data/lib/hecks/runtime/command_rules/emission.rb +34 -0
- data/lib/hecks/runtime/command_rules/references.rb +189 -0
- data/lib/hecks/runtime/command_rules.rb +28 -0
- data/lib/hecks/runtime/dependency_planning.rb +245 -0
- data/lib/hecks/runtime/dispatcher.rb +301 -0
- data/lib/hecks/runtime/entity_element.rb +253 -0
- data/lib/hecks/runtime/entity_interpreter.rb +299 -0
- data/lib/hecks/runtime/errors.rb +122 -0
- data/lib/hecks/runtime/event.rb +51 -0
- data/lib/hecks/runtime/identity.rb +156 -0
- data/lib/hecks/runtime/instance.rb +175 -0
- data/lib/hecks/runtime/interpreting.rb +90 -0
- data/lib/hecks/runtime/loader.rb +184 -0
- data/lib/hecks/runtime/policy_interpreter.rb +366 -0
- data/lib/hecks/runtime/port_operation_interpreter.rb +210 -0
- data/lib/hecks/runtime/query_interpreter.rb +286 -0
- data/lib/hecks/runtime/reaction_invocation.rb +253 -0
- data/lib/hecks/runtime/read_model_interpreter.rb +341 -0
- data/lib/hecks/runtime/rebuild_sweep.rb +74 -0
- data/lib/hecks/runtime/reference_hop.rb +99 -0
- data/lib/hecks/runtime/refusal_wording.rb +126 -0
- data/lib/hecks/runtime/registry/saga_persistence.rb +142 -0
- data/lib/hecks/runtime/registry/verification.rb +246 -0
- data/lib/hecks/runtime/registry.rb +279 -0
- data/lib/hecks/runtime/remote_dispatcher.rb +143 -0
- data/lib/hecks/runtime/routing.rb +96 -0
- data/lib/hecks/runtime/saga_interpreter/correlation.rb +97 -0
- data/lib/hecks/runtime/saga_interpreter.rb +503 -0
- data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
- data/lib/hecks/runtime/tenant_check.rb +84 -0
- data/lib/hecks/runtime/tenant_scope.rb +55 -0
- data/lib/hecks/runtime/value/admission.rb +130 -0
- data/lib/hecks/runtime/value/coercion.rb +565 -0
- data/lib/hecks/runtime/value/invariant_violation.rb +5 -0
- data/lib/hecks/runtime/value.rb +125 -0
- data/lib/hecks/runtime.rb +107 -0
- data/lib/hecks/storehouse.rb +632 -0
- data/lib/hecks/version.rb +16 -0
- data/lib/hecks/vocabulary.rb +218 -0
- data/lib/hecks.rb +123 -6
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
- metadata +412 -222
- data/bin/hecks +0 -7
- data/bin/hecks-package +0 -65
- data/bin/hecks_console +0 -12
- data/bin/hecks_serverless +0 -6
- data/lib/cli/build.rb +0 -14
- data/lib/cli/command_runner.rb +0 -28
- data/lib/cli/console.rb +0 -10
- data/lib/cli/generate.rb +0 -37
- data/lib/cli/hecks-cli.rb +0 -27
- data/lib/cli/test.rb +0 -57
- data/lib/console/commands.rb +0 -8
- data/lib/console/hecks-console.rb +0 -1
- data/lib/packager/README.md +0 -0
- data/lib/packager/app_runner.rb +0 -21
- data/lib/packager/args.rb +0 -26
- data/lib/packager/compatibility/fixnum.rb +0 -6
- data/lib/packager/hecks.rb +0 -39
- data/lib/packager/query_runner.rb +0 -21
- data/lib/packager/resources/Dockerfile +0 -11
- data/lib/packager/resources/app_binary +0 -7
- data/lib/packager/resources/bundle_config +0 -3
- data/lib/packager/resources/traveling-ruby-20150715-2.2.2-linux-x86_64.tar.gz +0 -0
- data/lib/packager/resources/traveling-ruby-20150715-2.2.2-osx.tar.gz +0 -0
- data/lib/serverless/Domain +0 -32
- data/lib/serverless/cli.rb +0 -75
- data/lib/serverless/resources/command_name.js +0 -5
- data/lib/serverless/resources/environment.js +0 -7
- data/lib/serverless/resources/handler.js.tt +0 -28
- data/lib/serverless/resources/run_binary.js +0 -22
- data/lib/serverless/resources/serverless.yml +0 -20
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Adapters
|
|
3
|
+
class PostgresEra
|
|
4
|
+
class Lineage
|
|
5
|
+
module Provisioning
|
|
6
|
+
# Provisioning is the OWNER's job, and a deployment's app role is
|
|
7
|
+
# deliberately not the owner — it may append and read, and it
|
|
8
|
+
# owns nothing. By the time such a role connects, the base is
|
|
9
|
+
# already built, so its boot verifies rather than builds.
|
|
10
|
+
#
|
|
11
|
+
# Without this guard the per-era fence below is unreachable: the
|
|
12
|
+
# ALTER TABLE and REVOKE here are owner-only, so the very role
|
|
13
|
+
# grant_era! exists to constrain could never finish booting
|
|
14
|
+
# ("must be owner of table hecks_eras"). A fence nothing can
|
|
15
|
+
# reach is not a fence.
|
|
16
|
+
def ensure_base!
|
|
17
|
+
rename_domain! if @formerly_known_as
|
|
18
|
+
return unless provisioner?
|
|
19
|
+
|
|
20
|
+
@db.exec(<<~SQL)
|
|
21
|
+
CREATE TABLE IF NOT EXISTS hecks_eras (
|
|
22
|
+
domain text NOT NULL,
|
|
23
|
+
ordinal int NOT NULL,
|
|
24
|
+
hash text,
|
|
25
|
+
label text,
|
|
26
|
+
held_text text NOT NULL,
|
|
27
|
+
watermark bigint,
|
|
28
|
+
PRIMARY KEY (domain, ordinal)
|
|
29
|
+
)
|
|
30
|
+
SQL
|
|
31
|
+
@db.exec("ALTER TABLE hecks_eras ADD COLUMN IF NOT EXISTS held_digest text")
|
|
32
|
+
@db.exec("ALTER TABLE hecks_eras ADD COLUMN IF NOT EXISTS held_projection jsonb")
|
|
33
|
+
# which canonical-form version minted this name — see
|
|
34
|
+
# Runtime::StorageShape::FORM_VERSION; rows minted before the
|
|
35
|
+
# column carry NULL, read as an implicit 1
|
|
36
|
+
@db.exec("ALTER TABLE hecks_eras ADD COLUMN IF NOT EXISTS canon_form int")
|
|
37
|
+
# every frozen text version, archived where an edit cannot
|
|
38
|
+
# reach it — the recovery the hard reattest refusal points at
|
|
39
|
+
@db.exec(<<~SQL)
|
|
40
|
+
CREATE TABLE IF NOT EXISTS hecks_era_texts (
|
|
41
|
+
domain text NOT NULL,
|
|
42
|
+
ordinal int NOT NULL,
|
|
43
|
+
digest text NOT NULL,
|
|
44
|
+
held_text text NOT NULL,
|
|
45
|
+
archived_at timestamptz NOT NULL DEFAULT now(),
|
|
46
|
+
PRIMARY KEY (domain, ordinal, digest)
|
|
47
|
+
)
|
|
48
|
+
SQL
|
|
49
|
+
@db.exec(<<~SQL)
|
|
50
|
+
CREATE TABLE IF NOT EXISTS hecks_approvals (
|
|
51
|
+
domain text NOT NULL,
|
|
52
|
+
from_label text NOT NULL,
|
|
53
|
+
to_label text NOT NULL,
|
|
54
|
+
edge_digest text NOT NULL,
|
|
55
|
+
reviewed_ordinal bigint NOT NULL,
|
|
56
|
+
approved_at timestamptz NOT NULL DEFAULT now()
|
|
57
|
+
)
|
|
58
|
+
SQL
|
|
59
|
+
@db.exec("CREATE SEQUENCE IF NOT EXISTS #{quote(sequence)}")
|
|
60
|
+
# GENERATED ALWAYS AS IDENTITY is the intent, but identity
|
|
61
|
+
# columns on partitioned tables need Postgres 17 — an owned
|
|
62
|
+
# sequence default is the same spanning ordinal on any
|
|
63
|
+
# supported server.
|
|
64
|
+
@db.exec(<<~SQL)
|
|
65
|
+
CREATE TABLE IF NOT EXISTS #{quoted_journal} (
|
|
66
|
+
ordinal bigint NOT NULL DEFAULT nextval('#{sequence}'),
|
|
67
|
+
era int NOT NULL,
|
|
68
|
+
aggregate text NOT NULL,
|
|
69
|
+
aggregate_id text NOT NULL,
|
|
70
|
+
operation text NOT NULL DEFAULT 'save',
|
|
71
|
+
state jsonb,
|
|
72
|
+
mirrors jsonb
|
|
73
|
+
) PARTITION BY LIST (era)
|
|
74
|
+
SQL
|
|
75
|
+
ensure_partition!(1)
|
|
76
|
+
# Immutability by privilege: nothing updates or deletes journal
|
|
77
|
+
# rows. The owner's implicit rights remain (Postgres has no way
|
|
78
|
+
# to revoke them from the owner itself); a deployment's app
|
|
79
|
+
# role connects as a NON-owner and gets exactly INSERT, per
|
|
80
|
+
# era, at mint time.
|
|
81
|
+
#
|
|
82
|
+
# GUARDED, same reasoning as the RLS ALTER TABLE calls just
|
|
83
|
+
# below: REVOKE still writes pg_class.relacl (and takes the
|
|
84
|
+
# matching lock) even when the resulting privileges are
|
|
85
|
+
# unchanged, so an unconditional reissue on every ordinary
|
|
86
|
+
# reboot raced two concurrent boots into
|
|
87
|
+
# `PG::InternalError: tuple concurrently updated` — read
|
|
88
|
+
# first, touch the catalog only on the boot that actually
|
|
89
|
+
# needs to.
|
|
90
|
+
#
|
|
91
|
+
# `relacl IS NULL`, not `has_table_privilege('public', ...,
|
|
92
|
+
# 'UPDATE')` — found live, not assumed: a brand-new table's
|
|
93
|
+
# PUBLIC privilege is already "no UPDATE" by Postgres's own
|
|
94
|
+
# default (nothing has ever been explicitly granted to
|
|
95
|
+
# PUBLIC), so `has_table_privilege` answers false BOTH before
|
|
96
|
+
# the REVOKE has ever run AND after it has — indistinguishable
|
|
97
|
+
# by that check alone, which meant the very first boot's own
|
|
98
|
+
# REVOKE never actually ran, `relacl` stayed NULL forever, and
|
|
99
|
+
# "journal rows accept no UPDATE/DELETE from PUBLIC" was true
|
|
100
|
+
# only by accident of Postgres's default, not by the explicit
|
|
101
|
+
# privilege revocation this method exists to record.
|
|
102
|
+
# `relacl IS NULL` means "default ACL, nothing explicit yet" —
|
|
103
|
+
# exactly the one-time signal needed, and (like the RLS flags
|
|
104
|
+
# below) `pg_table_is_visible(oid)`, not a bare relname match,
|
|
105
|
+
# for the same shared-instance/storehouse reason.
|
|
106
|
+
relacl_null = @db.exec_params(
|
|
107
|
+
"SELECT relacl IS NULL FROM pg_class WHERE relname = $1 AND pg_table_is_visible(oid)", [journal]
|
|
108
|
+
).getvalue(0, 0)
|
|
109
|
+
@db.exec("REVOKE UPDATE, DELETE ON #{quoted_journal} FROM PUBLIC") if relacl_null == "t"
|
|
110
|
+
# RLS goes on AT PROVISIONING, never mid-life — enabling it
|
|
111
|
+
# later would deny every role that has no policy yet, on
|
|
112
|
+
# whatever the shape of the schema happened to be at that
|
|
113
|
+
# moment.
|
|
114
|
+
#
|
|
115
|
+
# FORCE, not merely ENABLE: without it, the table OWNER is
|
|
116
|
+
# exempt from every policy here, by Postgres default — which
|
|
117
|
+
# would leave the schema writable forever to whoever holds
|
|
118
|
+
# the owner's credentials, the one connection this whole
|
|
119
|
+
# design cannot fence. Checked, not assumed: mint_era! never
|
|
120
|
+
# inserts into the journal at all (only hecks_eras/
|
|
121
|
+
# hecks_era_texts, neither RLS-protected), and merge_tail!'s
|
|
122
|
+
# one journal INSERT targets the CURRENT era, which the fence
|
|
123
|
+
# already admits for anyone with base privileges — so FORCE
|
|
124
|
+
# costs the owner nothing operations here actually need.
|
|
125
|
+
#
|
|
126
|
+
# This still exempts an actual Postgres SUPERUSER (or any
|
|
127
|
+
# role granted BYPASSRLS) unconditionally — FORCE only
|
|
128
|
+
# narrows what ENABLE already narrows for the owner
|
|
129
|
+
# specifically, and superuser bypass sits above both. Running
|
|
130
|
+
# migrations as a real superuser (self-hosted Postgres, most
|
|
131
|
+
# commonly) leaves this gap open regardless; a managed
|
|
132
|
+
# provider's admin account is typically NOT a superuser, and
|
|
133
|
+
# is exactly what FORCE closes.
|
|
134
|
+
#
|
|
135
|
+
# GUARDED, not reissued unconditionally — measured, not
|
|
136
|
+
# assumed: `ALTER TABLE ... ENABLE/FORCE ROW LEVEL SECURITY`
|
|
137
|
+
# takes AccessExclusiveLock EVEN WHEN THE SETTING IS ALREADY
|
|
138
|
+
# CORRECT (Postgres does not skip the lock just because the
|
|
139
|
+
# statement would be a no-op). ensure_base! runs on EVERY
|
|
140
|
+
# boot by the owning role, not only the first — so an
|
|
141
|
+
# unconditional reissue here would mean every ordinary
|
|
142
|
+
# reboot of the deployment's own identity re-freezes every
|
|
143
|
+
# concurrent writer, on any era, for as long as that ALTER
|
|
144
|
+
# TABLE has to wait its turn. Read the current state first;
|
|
145
|
+
# touch the catalog only on the boot that actually needs to.
|
|
146
|
+
# pg_table_is_visible, NOT a bare relname match — a shared
|
|
147
|
+
# instance (storehouse) can hold a same-named journal table
|
|
148
|
+
# per schema; catalog lookups here must resolve the SAME way
|
|
149
|
+
# search_path resolves an unqualified SQL reference, or a
|
|
150
|
+
# sibling domain's table satisfies a query meant for this
|
|
151
|
+
# domain's own.
|
|
152
|
+
current = @db.exec_params(
|
|
153
|
+
"SELECT relrowsecurity, relforcerowsecurity FROM pg_class " \
|
|
154
|
+
"WHERE relname = $1 AND pg_table_is_visible(oid)", [journal]
|
|
155
|
+
)[0]
|
|
156
|
+
@db.exec("ALTER TABLE #{quoted_journal} ENABLE ROW LEVEL SECURITY") unless current["relrowsecurity"] == "t"
|
|
157
|
+
@db.exec("ALTER TABLE #{quoted_journal} FORCE ROW LEVEL SECURITY") unless current["relforcerowsecurity"] == "t"
|
|
158
|
+
install_transforms!
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# A DOMAIN'S OWN IDENTITY CHANGED — bridge its history under the
|
|
162
|
+
# new name, before `provisioner?`/the CREATE TABLE IF NOT EXISTS
|
|
163
|
+
# block below ever run. That ordering is load-bearing, not
|
|
164
|
+
# tidiness: `provisioner?` and every statement in ensure_base!
|
|
165
|
+
# test for the journal under `journal` — the NEW name — and a
|
|
166
|
+
# freshly-renamed domain looks, to those checks, exactly like a
|
|
167
|
+
# domain that has never been provisioned at all. Left where it
|
|
168
|
+
# was written, ensure_base! would happily CREATE TABLE IF NOT
|
|
169
|
+
# EXISTS a brand-new, empty journal under the new name before
|
|
170
|
+
# this method ever got a chance to run — and the ALTER TABLE ...
|
|
171
|
+
# RENAME below would then fail, renaming onto a name that
|
|
172
|
+
# already exists.
|
|
173
|
+
#
|
|
174
|
+
# Three-way precheck, cheapest first:
|
|
175
|
+
# 1. no hecks_eras table at all yet — a genuinely fresh
|
|
176
|
+
# database; nothing to bridge, fall through to the
|
|
177
|
+
# ordinary provisioning path.
|
|
178
|
+
# 2. hecks_eras already has rows under the NEW name — this
|
|
179
|
+
# rename already ran (a prior boot, possibly this one on a
|
|
180
|
+
# retry); idempotent no-op.
|
|
181
|
+
# 3. hecks_eras has rows under the OLD name — run the
|
|
182
|
+
# migration.
|
|
183
|
+
# Anything else (no rows under either name) falls through
|
|
184
|
+
# harmlessly — `formerly_known_as` pointing at a name with no
|
|
185
|
+
# held history is not an error, just inert.
|
|
186
|
+
def rename_domain!
|
|
187
|
+
return unless @db.exec_params("SELECT to_regclass($1)", ["hecks_eras"])[0]["to_regclass"]
|
|
188
|
+
return if @db.exec_params(
|
|
189
|
+
"SELECT 1 FROM hecks_eras WHERE domain = $1 LIMIT 1", [@domain]
|
|
190
|
+
).ntuples.positive?
|
|
191
|
+
return if @db.exec_params(
|
|
192
|
+
"SELECT 1 FROM hecks_eras WHERE domain = $1 LIMIT 1", [@formerly_known_as]
|
|
193
|
+
).ntuples.zero?
|
|
194
|
+
|
|
195
|
+
old_journal = "hecks_journal_#{Naming.snake(@formerly_known_as)}"
|
|
196
|
+
old_sequence = "#{old_journal}_ordinal"
|
|
197
|
+
ordinals = @db.exec_params(
|
|
198
|
+
"SELECT ordinal FROM hecks_eras WHERE domain = $1 ORDER BY ordinal", [@formerly_known_as]
|
|
199
|
+
).map { |row| row["ordinal"].to_i }
|
|
200
|
+
|
|
201
|
+
@db.exec("BEGIN")
|
|
202
|
+
@db.exec("SET LOCAL lock_timeout = '10s'")
|
|
203
|
+
# Fixed order — old before new, the `eras:` family before the
|
|
204
|
+
# `ordinal:` family — so two rename attempts (or a rename
|
|
205
|
+
# racing a mint/plain-write on either name) can only ever
|
|
206
|
+
# queue behind one another, never deadlock.
|
|
207
|
+
[
|
|
208
|
+
"hecks_eras:#{@formerly_known_as}", "hecks_eras:#{@domain}",
|
|
209
|
+
"hecks_ordinal:#{@formerly_known_as}", "hecks_ordinal:#{@domain}"
|
|
210
|
+
].each do |key|
|
|
211
|
+
@db.exec_params("SELECT pg_advisory_xact_lock(hashtext($1))", [key])
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
@db.exec("ALTER TABLE #{quote(old_journal)} RENAME TO #{quote(journal)}")
|
|
215
|
+
# An owned SERIAL/IDENTITY sequence moves automatically with
|
|
216
|
+
# its table and errors if renamed explicitly — this one is a
|
|
217
|
+
# plain CREATE SEQUENCE the journal's DEFAULT merely points
|
|
218
|
+
# at (see ensure_base!), so it does need its own rename, and
|
|
219
|
+
# the column default survives untouched: Postgres stores
|
|
220
|
+
# nextval('...') as a regclass reference internally, not
|
|
221
|
+
# literal text.
|
|
222
|
+
@db.exec("ALTER SEQUENCE #{quote(old_sequence)} RENAME TO #{quote(sequence)}")
|
|
223
|
+
# ALTER TABLE ... RENAME on the parent does NOT cascade to
|
|
224
|
+
# child partition names — each one needs its own explicit
|
|
225
|
+
# rename, sourced from the ordinals held under the OLD name,
|
|
226
|
+
# captured above before the UPDATE below flips the column.
|
|
227
|
+
ordinals.each do |ordinal|
|
|
228
|
+
old_partition = "#{old_journal}_era_#{ordinal}"
|
|
229
|
+
@db.exec("ALTER TABLE #{quote(old_partition)} RENAME TO #{quote(partition(ordinal))}")
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
%w[hecks_eras hecks_era_texts hecks_approvals].each do |table|
|
|
233
|
+
@db.exec_params("UPDATE #{table} SET domain = $1 WHERE domain = $2", [@domain, @formerly_known_as])
|
|
234
|
+
end
|
|
235
|
+
# Unlike its siblings, hecks_attestations is not created in
|
|
236
|
+
# ensure_base! at all — only lazily, on first reattest! — so a
|
|
237
|
+
# domain that never needed one must not be forced through an
|
|
238
|
+
# UPDATE against a table that doesn't exist.
|
|
239
|
+
@db.exec_params("UPDATE hecks_attestations SET domain = $1 WHERE domain = $2", [@domain, @formerly_known_as]) if @db.exec_params("SELECT to_regclass($1)", ["hecks_attestations"])[0]["to_regclass"]
|
|
240
|
+
|
|
241
|
+
@db.exec("COMMIT")
|
|
242
|
+
rescue PG::LockNotAvailable
|
|
243
|
+
@db.exec("ROLLBACK") rescue nil
|
|
244
|
+
raise Runtime::WiringError,
|
|
245
|
+
"cannot rename #{@formerly_known_as} to #{@domain}: another rename, mint, or write holds " \
|
|
246
|
+
"one of the domain locks — waited 10s; try again shortly"
|
|
247
|
+
rescue PG::Error => error
|
|
248
|
+
@db.exec("ROLLBACK") rescue nil
|
|
249
|
+
raise Runtime::WiringError, "cannot rename #{@formerly_known_as} to #{@domain}: #{error.message.strip}"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Nothing provisioned yet — build it. Provisioned and owned —
|
|
253
|
+
# keep it current. Provisioned by SOMEONE ELSE — this is an app
|
|
254
|
+
# role, and the owner has already done this work.
|
|
255
|
+
def provisioner?
|
|
256
|
+
rows = @db.exec_params(
|
|
257
|
+
"SELECT pg_get_userbyid(relowner) = current_user AS owned FROM pg_class " \
|
|
258
|
+
"WHERE relname = $1 AND pg_table_is_visible(oid)",
|
|
259
|
+
[journal]
|
|
260
|
+
)
|
|
261
|
+
rows.ntuples.zero? || rows[0]["owned"] == "t"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# BUILD, THEN ATTACH — never CREATE ... PARTITION OF. The two
|
|
265
|
+
# produce the same partition; only the lock differs, and that
|
|
266
|
+
# difference is the whole availability story of a mint:
|
|
267
|
+
#
|
|
268
|
+
# CREATE TABLE ... PARTITION OF → AccessExclusiveLock (parent)
|
|
269
|
+
# CREATE, then ALTER ... ATTACH → ShareUpdateExclusiveLock
|
|
270
|
+
#
|
|
271
|
+
# AccessExclusive conflicts with every insert in the hierarchy —
|
|
272
|
+
# routed through the parent OR addressed to an existing leaf —
|
|
273
|
+
# so attaching the new era inside the mint transaction stopped
|
|
274
|
+
# every writer for the WHOLE mint, tail materialization
|
|
275
|
+
# included. ShareUpdateExclusive conflicts with neither, so the
|
|
276
|
+
# old checkout keeps writing its own era straight through the
|
|
277
|
+
# build and only pauses for the head swap at the end.
|
|
278
|
+
#
|
|
279
|
+
# That is what makes the fork real DURING a mint rather than
|
|
280
|
+
# merely before and after one. Measured, and pinned by the spec
|
|
281
|
+
# — which writes through a live mint rather than reading a lock
|
|
282
|
+
# mode out of the catalog.
|
|
283
|
+
def ensure_partition!(era)
|
|
284
|
+
return if partition_attached?(era)
|
|
285
|
+
|
|
286
|
+
@db.exec(<<~SQL)
|
|
287
|
+
CREATE TABLE IF NOT EXISTS #{quote(partition(era))} (
|
|
288
|
+
LIKE #{quoted_journal} INCLUDING DEFAULTS
|
|
289
|
+
)
|
|
290
|
+
SQL
|
|
291
|
+
@db.exec(<<~SQL)
|
|
292
|
+
ALTER TABLE #{quoted_journal}
|
|
293
|
+
ATTACH PARTITION #{quote(partition(era))} FOR VALUES IN (#{era.to_i})
|
|
294
|
+
SQL
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Attached, not merely present: a crash between the CREATE and
|
|
298
|
+
# the ATTACH leaves a table that is not yet part of the journal,
|
|
299
|
+
# and the next boot must finish the job rather than skip it.
|
|
300
|
+
def partition_attached?(era)
|
|
301
|
+
@db.exec_params(
|
|
302
|
+
"SELECT 1 FROM pg_inherits i " \
|
|
303
|
+
"JOIN pg_class child ON child.oid = i.inhrelid " \
|
|
304
|
+
"JOIN pg_class parent ON parent.oid = i.inhparent " \
|
|
305
|
+
"WHERE child.relname = $1 AND parent.relname = $2 " \
|
|
306
|
+
"AND pg_table_is_visible(child.oid) AND pg_table_is_visible(parent.oid)",
|
|
307
|
+
[partition(era), journal]
|
|
308
|
+
).ntuples.positive?
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Adapters
|
|
3
|
+
class PostgresEra
|
|
4
|
+
class Lineage
|
|
5
|
+
# THE ONE CHUNKED, LOCK-FREE, RESUMABLE BACKFILL LOOP — shared by
|
|
6
|
+
# `backfill_head_snapshot!` (era 1's existing one-shot blocking
|
|
7
|
+
# backfill, retrofit) and every field-cache table's own initial
|
|
8
|
+
# backfill (new). Governing principle 1 (docs/implemented/postgres-era-adapter-
|
|
9
|
+
# split-plan.md): no operation this plan touches may hold a lock
|
|
10
|
+
# across a scan whose duration scales with table size — a single
|
|
11
|
+
# `INSERT ... SELECT` over the whole journal (what
|
|
12
|
+
# `backfill_head_snapshot!` used to be) is exactly that, and so is
|
|
13
|
+
# a naive "populate every cache row in one statement" field-cache
|
|
14
|
+
# backfill.
|
|
15
|
+
#
|
|
16
|
+
# THE SHAPE: read one bounded chunk (real rows, real ordinals) with
|
|
17
|
+
# a plain SELECT — no lock held across it, so an ordinary reader or
|
|
18
|
+
# writer is never blocked by a backfill in progress — then upsert
|
|
19
|
+
# that chunk under the SAME transactionally-scoped advisory lock +
|
|
20
|
+
# ordinal-guard idiom `append`'s own snapshot upsert already uses
|
|
21
|
+
# (`WHERE ordinal < EXCLUDED.ordinal`), then persist a cursor
|
|
22
|
+
# before moving to the next chunk. Repeat until a chunk reads back
|
|
23
|
+
# short of a full page — that page was the last one.
|
|
24
|
+
#
|
|
25
|
+
# RESUMABLE, not merely restartable. A crash (or a second
|
|
26
|
+
# concurrent boot) mid-backfill leaves the cursor exactly where the
|
|
27
|
+
# last COMMITTED chunk left it — `hecks_backfill_progress` is
|
|
28
|
+
# updated in the SAME transaction as the chunk's own upsert, so
|
|
29
|
+
# cursor and data can never observably disagree (see
|
|
30
|
+
# `run_chunk!`). The next attempt reads that cursor and continues;
|
|
31
|
+
# it does not rescan what a prior attempt already committed.
|
|
32
|
+
# RESTARTABLE would also be CORRECT here (every upsert is
|
|
33
|
+
# idempotent and ordinal-guarded — rerunning an already-done chunk
|
|
34
|
+
# from id 1 changes nothing) but wastes real work on a large
|
|
35
|
+
# table; resumability is what keeps a crash near the END of a
|
|
36
|
+
# large backfill cheap to recover from instead of starting over.
|
|
37
|
+
#
|
|
38
|
+
# THE LOCK KEY PREFIX is `hecks_field_cache:` — deliberately
|
|
39
|
+
# disjoint from the three families already in use elsewhere in
|
|
40
|
+
# this adapter (`hecks_ordinal:`, `hecks_eras:`,
|
|
41
|
+
# `hecks_head_snapshot:` — see lineage.rb/head_compiler.rb/
|
|
42
|
+
# mint_transaction.rb/tail_merge.rb) so a backfill chunk NEVER
|
|
43
|
+
# contends with a plain write, a mint, or a snapshot-table's own
|
|
44
|
+
# first-creation lock. It is held for exactly ONE CHUNK's own
|
|
45
|
+
# transaction, never across the whole backfill — two concurrent
|
|
46
|
+
# backfillers of the SAME target simply take turns one chunk at a
|
|
47
|
+
# time rather than racing to duplicate work; neither blocks an
|
|
48
|
+
# unrelated reader or writer for even an instant.
|
|
49
|
+
module ResumableBackfill
|
|
50
|
+
CHUNK_SIZE = 5_000
|
|
51
|
+
|
|
52
|
+
# Idempotent, unguarded — same idiom as every other DDL helper
|
|
53
|
+
# in this file tree (`ensure_head_snapshot!` et al.): cheap,
|
|
54
|
+
# runs on every boot, only ever does real work once.
|
|
55
|
+
def ensure_backfill_progress_table!
|
|
56
|
+
@db.exec(<<~SQL)
|
|
57
|
+
CREATE TABLE IF NOT EXISTS hecks_backfill_progress (
|
|
58
|
+
target text PRIMARY KEY,
|
|
59
|
+
cursor text,
|
|
60
|
+
completed boolean NOT NULL DEFAULT false,
|
|
61
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
62
|
+
)
|
|
63
|
+
SQL
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Drives `target` (an already-created, currently-empty-or-
|
|
67
|
+
# partially-filled table) through chunks until a source read
|
|
68
|
+
# comes back short of `CHUNK_SIZE` rows. Two distinct callables,
|
|
69
|
+
# not one — a head-snapshot row and a field-cache row carry
|
|
70
|
+
# different columns (`state` jsonb vs. a single extracted
|
|
71
|
+
# `value`), so there is no one generic "upsert this row" shape
|
|
72
|
+
# to share; only the LOOP, the lock, and the cursor are generic.
|
|
73
|
+
#
|
|
74
|
+
# source_sql.call(cursor) — given the last-processed id (nil
|
|
75
|
+
# before the first chunk), returns a SQL SELECT whose result
|
|
76
|
+
# has an `id` column (text, ordered ascending) plus whatever
|
|
77
|
+
# other columns `upsert` below needs. Must read `id >
|
|
78
|
+
# cursor` (or unconditional when cursor is nil), `ORDER BY
|
|
79
|
+
# id`, `LIMIT CHUNK_SIZE` — the caller owns the actual
|
|
80
|
+
# column list/source tables; this method only owns the loop,
|
|
81
|
+
# the lock, and the cursor.
|
|
82
|
+
#
|
|
83
|
+
# upsert.call(rows) — given the PG::Result of one chunk's
|
|
84
|
+
# read, performs the actual guarded upsert into `target` and
|
|
85
|
+
# returns nothing meaningful; runs INSIDE the same
|
|
86
|
+
# transaction/advisory-lock scope as the cursor update below,
|
|
87
|
+
# so a crash between "wrote the chunk" and "advanced the
|
|
88
|
+
# cursor" is impossible — they commit together or not at
|
|
89
|
+
# all.
|
|
90
|
+
def chunked_backfill!(target, source_sql:, upsert:)
|
|
91
|
+
ensure_backfill_progress_table!
|
|
92
|
+
loop do
|
|
93
|
+
done = run_chunk!(target, source_sql: source_sql, upsert: upsert)
|
|
94
|
+
break if done
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# ONE CHUNK, ONE TRANSACTION, ONE SHORT-HELD LOCK. Re-reads
|
|
101
|
+
# progress AFTER acquiring the lock (not just before) — a second
|
|
102
|
+
# concurrent booter may have already finished this exact chunk
|
|
103
|
+
# (or the whole backfill) while this process was waiting for the
|
|
104
|
+
# lock; without the re-read, it would redundantly reprocess a
|
|
105
|
+
# chunk another process just committed. Harmless either way
|
|
106
|
+
# (idempotent, ordinal-guarded) but the re-read is what keeps
|
|
107
|
+
# two concurrent boots from both doing the full scan instead of
|
|
108
|
+
# splitting it.
|
|
109
|
+
def run_chunk!(target, source_sql:, upsert:)
|
|
110
|
+
progress = backfill_progress(target)
|
|
111
|
+
return true if progress[:completed]
|
|
112
|
+
|
|
113
|
+
completed = false
|
|
114
|
+
nested_transaction("hecks_backfill_chunk") do
|
|
115
|
+
@db.exec_params("SELECT pg_advisory_xact_lock(hashtext('hecks_field_cache:' || $1))", [target])
|
|
116
|
+
progress = backfill_progress(target)
|
|
117
|
+
if progress[:completed]
|
|
118
|
+
completed = true
|
|
119
|
+
next
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
rows = @db.exec(source_sql.call(progress[:cursor]))
|
|
123
|
+
if rows.ntuples.zero?
|
|
124
|
+
upsert_backfill_progress!(target, cursor: progress[:cursor], completed: true)
|
|
125
|
+
completed = true
|
|
126
|
+
next
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
upsert.call(rows)
|
|
130
|
+
completed = rows.ntuples < CHUNK_SIZE
|
|
131
|
+
# `PG::Result#[]` supports neither an out-of-range index NOR
|
|
132
|
+
# a negative one (unlike a plain Ruby Array) — same trap as
|
|
133
|
+
# `backfill_progress` above, the explicit last-index form.
|
|
134
|
+
last_cursor = rows[rows.ntuples - 1]["id"]
|
|
135
|
+
upsert_backfill_progress!(target, cursor: last_cursor, completed: completed)
|
|
136
|
+
end
|
|
137
|
+
completed
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# `PG::Result#[]` RAISES IndexError on an out-of-range index —
|
|
141
|
+
# unlike a plain Ruby Array, it does not return nil — so the
|
|
142
|
+
# ENTIRELY ORDINARY case of "no progress row exists yet" (every
|
|
143
|
+
# target's very first check) cannot be read via a bare `[0]`.
|
|
144
|
+
# `ntuples.zero?` first, always.
|
|
145
|
+
def backfill_progress(target)
|
|
146
|
+
result = @db.exec_params(
|
|
147
|
+
"SELECT cursor, completed FROM hecks_backfill_progress WHERE target = $1", [target]
|
|
148
|
+
)
|
|
149
|
+
return { cursor: nil, completed: false } if result.ntuples.zero?
|
|
150
|
+
|
|
151
|
+
row = result[0]
|
|
152
|
+
{ cursor: row["cursor"], completed: row["completed"] == "t" }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def upsert_backfill_progress!(target, cursor:, completed:)
|
|
156
|
+
@db.exec_params(
|
|
157
|
+
"INSERT INTO hecks_backfill_progress (target, cursor, completed, updated_at) " \
|
|
158
|
+
"VALUES ($1, $2, $3, now()) " \
|
|
159
|
+
"ON CONFLICT (target) DO UPDATE SET cursor = EXCLUDED.cursor, " \
|
|
160
|
+
"completed = EXCLUDED.completed, updated_at = EXCLUDED.updated_at",
|
|
161
|
+
[target, cursor, completed]
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
require_relative "../../../../../../runtime/registry"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Adapters
|
|
5
|
+
class PostgresEra
|
|
6
|
+
class Lineage
|
|
7
|
+
module TailMerge
|
|
8
|
+
# ── fork observability ─────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
# Post-cut writes an old era made after the newer era was minted
|
|
11
|
+
# — the divergence between the worlds, observable at any time.
|
|
12
|
+
def diverged_count(old_era)
|
|
13
|
+
cut = eras.find { |era| era[:ordinal] == old_era + 1 }&.dig(:watermark)
|
|
14
|
+
return 0 unless cut
|
|
15
|
+
|
|
16
|
+
@db.exec(
|
|
17
|
+
"SELECT count(*) FROM #{quoted_journal} WHERE era = #{old_era.to_i} AND ordinal > #{cut.to_i}"
|
|
18
|
+
)[0]["count"].to_i
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# ── tail-merge ─────────────────────────────────────────────────
|
|
22
|
+
#
|
|
23
|
+
# The one deliberate command — it marks a business event (an app
|
|
24
|
+
# retiring), never a shape change. One transaction: advance the
|
|
25
|
+
# watermarks, rebuild the head so the tail interleaves by its
|
|
26
|
+
# recorded global ordinal, append the declared winners, audit —
|
|
27
|
+
# and roll the whole thing back on any refusal. Records touched
|
|
28
|
+
# by both worlds since the cut refuse until each has an explicit
|
|
29
|
+
# winner; resolution itself is append-only (the winner's state
|
|
30
|
+
# re-enters as the newest row and wins structurally — originals
|
|
31
|
+
# stay immutable).
|
|
32
|
+
def merge_tail!(aggregates:, edges:, winners: {}, audit: nil)
|
|
33
|
+
@db.exec("BEGIN")
|
|
34
|
+
@db.exec("SET LOCAL lock_timeout = '10s'")
|
|
35
|
+
@db.exec("SELECT pg_advisory_xact_lock(hashtext('hecks_eras:' || #{text_literal(@domain)}))")
|
|
36
|
+
held = eras
|
|
37
|
+
era = held.last[:ordinal]
|
|
38
|
+
label = held.last[:label]
|
|
39
|
+
if era == 1
|
|
40
|
+
@db.exec("ROLLBACK")
|
|
41
|
+
raise Runtime::WiringError, "nothing to merge — #{@domain} stands at era 1"
|
|
42
|
+
end
|
|
43
|
+
cut = held.last[:watermark].to_i
|
|
44
|
+
tip = last_ordinal
|
|
45
|
+
|
|
46
|
+
conflicts = aggregates.flat_map { |aggregate| conflict_ids(aggregate, edges, era, cut) }
|
|
47
|
+
unresolved = conflicts.reject { |_, id| winners.key?(id) }
|
|
48
|
+
unless unresolved.empty?
|
|
49
|
+
@db.exec("ROLLBACK")
|
|
50
|
+
raise Runtime::WiringError,
|
|
51
|
+
"cannot merge the tail of #{@domain}: touched by both worlds since the cut — " \
|
|
52
|
+
"#{unresolved.map { |storage, id| "#{storage}##{id}" }.sort.join(', ')}. " \
|
|
53
|
+
"Name each winner (--winner <id>=old or --winner <id>=new), then run bin/merge_tail again. " \
|
|
54
|
+
"A winner takes the WHOLE record — the aggregate is the consistency boundary, so the " \
|
|
55
|
+
"loser's edits are discarded even where they touched different attributes"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# the new world's pre-merge head states, captured before the
|
|
59
|
+
# rebuild lets the tail interleave
|
|
60
|
+
new_states = {}
|
|
61
|
+
aggregates.each do |aggregate|
|
|
62
|
+
winners.select { |_, side| side == "new" }.each_key do |id|
|
|
63
|
+
row = @db.exec_params("SELECT state FROM #{quote(head_view(aggregate.storage_name))} WHERE id = $1", [id])
|
|
64
|
+
new_states[id] = [aggregate.storage_name, row[0]["state"]] if row.ntuples.positive?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@db.exec_params("UPDATE hecks_eras SET watermark = $2 WHERE domain = $1 AND ordinal > 1", [@domain, tip])
|
|
69
|
+
aggregates.each do |aggregate|
|
|
70
|
+
@db.exec("DROP VIEW IF EXISTS #{quote(head_view(aggregate.storage_name))}")
|
|
71
|
+
@db.exec("DROP MATERIALIZED VIEW IF EXISTS #{quote(matview(aggregate.storage_name, era, label))}")
|
|
72
|
+
# full: the watermarks just moved — every ancestor matview's
|
|
73
|
+
# cut is stale, so there is nothing safe to layer on.
|
|
74
|
+
compile_head!(aggregate, era, label, edges, full: true)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# rubocop:disable-next Metrics/BlockLength
|
|
78
|
+
winners.each do |id, side|
|
|
79
|
+
aggregates.each do |aggregate|
|
|
80
|
+
state =
|
|
81
|
+
if side == "old"
|
|
82
|
+
row = @db.exec_params(
|
|
83
|
+
"SELECT state FROM #{quote(matview(aggregate.storage_name, era, label))} " \
|
|
84
|
+
"WHERE aggregate_id = $1 AND operation = 'save' ORDER BY ordinal DESC LIMIT 1",
|
|
85
|
+
[id]
|
|
86
|
+
)
|
|
87
|
+
row.ntuples.positive? ? row[0]["state"] : nil
|
|
88
|
+
else
|
|
89
|
+
new_states[id]&.first == aggregate.storage_name ? new_states[id][1] : nil
|
|
90
|
+
end
|
|
91
|
+
next unless state
|
|
92
|
+
|
|
93
|
+
# Same two-step append does for a live write — journal
|
|
94
|
+
# first, snapshot second — because this INSERT bypasses
|
|
95
|
+
# PostgresEra#append entirely (it writes through Lineage
|
|
96
|
+
# directly). Skipping the snapshot half here would mean a
|
|
97
|
+
# merge winner lands in the journal but head_view — which
|
|
98
|
+
# reads this era's live rows from the snapshot table, not
|
|
99
|
+
# by re-scanning the journal — never shows it.
|
|
100
|
+
ordinal = @db.exec_params(
|
|
101
|
+
"INSERT INTO #{quoted_journal} (era, aggregate, aggregate_id, operation, state) " \
|
|
102
|
+
"VALUES ($1, $2, $3, 'save', $4) RETURNING ordinal",
|
|
103
|
+
[era, aggregate.storage_name, id, state]
|
|
104
|
+
)[0]["ordinal"]
|
|
105
|
+
# Always `'save'` — a merge winner is read from either
|
|
106
|
+
# the matview's own `operation = 'save'` rows or the new
|
|
107
|
+
# world's live head (`new_states`, captured from
|
|
108
|
+
# `head_view`, which is save-only by construction), so
|
|
109
|
+
# nothing reaching this INSERT is ever a delete.
|
|
110
|
+
@db.exec_params(
|
|
111
|
+
"INSERT INTO #{quote(head_snapshot(aggregate.storage_name, era))} (id, ordinal, operation, state) " \
|
|
112
|
+
"VALUES ($1, $2, 'save', $3) " \
|
|
113
|
+
"ON CONFLICT (id) DO UPDATE SET ordinal = EXCLUDED.ordinal, operation = EXCLUDED.operation, " \
|
|
114
|
+
"state = EXCLUDED.state WHERE #{quote(head_snapshot(aggregate.storage_name, era))}.ordinal < EXCLUDED.ordinal",
|
|
115
|
+
[id, ordinal, state]
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
if audit
|
|
121
|
+
violations = audit.call
|
|
122
|
+
unless violations.empty?
|
|
123
|
+
@db.exec("ROLLBACK")
|
|
124
|
+
raise Runtime::WiringError,
|
|
125
|
+
"cannot merge the tail of #{@domain}: the audit refused —\n - #{violations.join("\n - ")}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
@db.exec("COMMIT")
|
|
130
|
+
true
|
|
131
|
+
rescue PG::LockNotAvailable
|
|
132
|
+
@db.exec("ROLLBACK") rescue nil
|
|
133
|
+
raise Runtime::WiringError,
|
|
134
|
+
"cannot merge the tail of #{@domain}: another mint or merge holds the domain lock — " \
|
|
135
|
+
"waited 10s; try again shortly"
|
|
136
|
+
rescue PG::Error => error
|
|
137
|
+
@db.exec("ROLLBACK") rescue nil
|
|
138
|
+
raise Runtime::WiringError, "cannot merge the tail of #{@domain}: #{error.message.strip}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Ids touched by BOTH worlds since the cut — the old world's
|
|
142
|
+
# post-cut tail INTERSECTed with the new world's own writes.
|
|
143
|
+
#
|
|
144
|
+
# KNOWN GAP, not silently risked: this compares raw
|
|
145
|
+
# `aggregate_id` values, with no notion of "these two different
|
|
146
|
+
# ids are the same entity, rekeyed." If a domain's history
|
|
147
|
+
# includes a rekey (see TranslationRekey) and is LATER
|
|
148
|
+
# merged here, a record's pre-rekey and post-rekey rows will
|
|
149
|
+
# never intersect — they just silently survive as two separate,
|
|
150
|
+
# unrelated-looking heads (a duplicate, not corruption: nothing
|
|
151
|
+
# here deletes or clobbers either side). Resolve any such
|
|
152
|
+
# duplicate manually after a merge; teaching this INTERSECT
|
|
153
|
+
# about a rekey mapping is real, separate work, deliberately
|
|
154
|
+
# out of scope for rekey's first pass.
|
|
155
|
+
def conflict_ids(aggregate, edges, era, cut)
|
|
156
|
+
names = names_by_era(aggregate, edges)
|
|
157
|
+
olds = (1...era).map { |ancestor| text_literal(names[:storage][ancestor - 1]) }.join(", ")
|
|
158
|
+
@db.exec(<<~SQL).map { |row| [aggregate.storage_name, row["aggregate_id"]] }
|
|
159
|
+
SELECT aggregate_id FROM #{quoted_journal}
|
|
160
|
+
WHERE era < #{era.to_i} AND aggregate IN (#{olds}) AND ordinal > #{cut.to_i}
|
|
161
|
+
INTERSECT
|
|
162
|
+
SELECT aggregate_id FROM #{quoted_journal}
|
|
163
|
+
WHERE era = #{era.to_i} AND aggregate = #{text_literal(names[:storage][era - 1])}
|
|
164
|
+
SQL
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|