hecks 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/exe/hecks +4 -6
- data/lib/hecks/adapters/driven/claude_code.rb +4 -104
- data/lib/hecks/adapters/driven/d1.rb +44 -237
- data/lib/hecks/adapters/driven/folder.rb +17 -76
- data/lib/hecks/adapters/driven/google_authentication.rb +15 -49
- data/lib/hecks/adapters/driven/governance_authorization.rb +23 -70
- data/lib/hecks/adapters/driven/heki/journal.rb +8 -41
- data/lib/hecks/adapters/driven/heki/saga_store.rb +12 -54
- data/lib/hecks/adapters/driven/heki/snapshot.rb +4 -12
- data/lib/hecks/adapters/driven/heki.rb +13 -42
- data/lib/hecks/adapters/driven/identity_registry.rb +3 -14
- data/lib/hecks/adapters/driven/in_memory_ordering.rb +9 -26
- data/lib/hecks/adapters/driven/in_process_key_vault.rb +2 -14
- data/lib/hecks/adapters/driven/lambda/client.rb +10 -51
- data/lib/hecks/adapters/driven/lambda.rb +10 -84
- data/lib/hecks/adapters/driven/local_storage.rb +5 -40
- data/lib/hecks/adapters/driven/memory.rb +17 -98
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +5 -21
- data/lib/hecks/adapters/driven/postgres/codec.rb +6 -28
- data/lib/hecks/adapters/driven/postgres/outbox.rb +3 -12
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +4 -30
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +34 -108
- data/lib/hecks/adapters/driven/postgres.rb +145 -134
- data/lib/hecks/adapters/driven/postgres_era.adapter +4 -15
- data/lib/hecks/adapters/driven/prism.rb +7 -30
- data/lib/hecks/adapters/driven/sql_query_builder.rb +21 -121
- data/lib/hecks/adapters/driven/sqlite/codec.rb +8 -40
- data/lib/hecks/adapters/driven/sqlite/projection.rb +26 -82
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +40 -84
- data/lib/hecks/adapters/driven/sqlite.rb +128 -65
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +5 -48
- data/lib/hecks/adapters/driven.rb +5 -36
- data/lib/hecks/adapters/driving/github_webhook.rb +8 -76
- data/lib/hecks/behaviors/dsl.rb +7 -25
- data/lib/hecks/behaviors/expectations.rb +31 -141
- data/lib/hecks/behaviors/ir.rb +7 -20
- data/lib/hecks/behaviors/rspec.rb +6 -17
- data/lib/hecks/behaviors/runner.rb +10 -34
- data/lib/hecks/behaviors.rb +4 -8
- data/lib/hecks/bluebook/aggregate.rb +9 -75
- data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +9 -38
- data/lib/hecks/bluebook/assembly/build.rb +4 -15
- data/lib/hecks/bluebook/assembly/contract.rb +15 -136
- data/lib/hecks/bluebook/assembly/contracts.rb +54 -174
- data/lib/hecks/bluebook/assembly/marks.rb +31 -223
- data/lib/hecks/bluebook/assembly/specializer.rb +6 -52
- data/lib/hecks/bluebook/assembly.rb +6 -45
- data/lib/hecks/bluebook/attribute.rb +12 -61
- data/lib/hecks/bluebook/behaviour/aggregate.rb +10 -39
- data/lib/hecks/bluebook/behaviour/attribute.rb +3 -14
- data/lib/hecks/bluebook/behaviour/chapter.rb +14 -42
- data/lib/hecks/bluebook/behaviour/command.rb +14 -66
- data/lib/hecks/bluebook/behaviour/domain_port.rb +4 -20
- data/lib/hecks/bluebook/behaviour/entity.rb +7 -27
- data/lib/hecks/bluebook/behaviour/hexagon.rb +23 -41
- data/lib/hecks/bluebook/behaviour/lifecycle.rb +15 -40
- data/lib/hecks/bluebook/behaviour/policy.rb +23 -52
- data/lib/hecks/bluebook/behaviour/process_manager.rb +30 -57
- data/lib/hecks/bluebook/behaviour/read_model.rb +25 -31
- data/lib/hecks/bluebook/behaviour/traits.rb +27 -64
- data/lib/hecks/bluebook/behaviour/value_object.rb +5 -24
- data/lib/hecks/bluebook/capabilities.rb +17 -49
- data/lib/hecks/bluebook/chapter.rb +10 -48
- data/lib/hecks/bluebook/command.rb +23 -81
- data/lib/hecks/bluebook/domain_port.rb +14 -63
- data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +50 -229
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +50 -377
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +33 -204
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +16 -69
- data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +86 -708
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +56 -170
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +74 -546
- data/lib/hecks/bluebook/dsl/const_shim.rb +8 -41
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +12 -87
- data/lib/hecks/bluebook/dsl/entity_builder.rb +44 -330
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +20 -269
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +26 -194
- data/lib/hecks/bluebook/dsl/identity_declaration.rb +13 -88
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +6 -20
- data/lib/hecks/bluebook/dsl/policy_builder.rb +16 -88
- data/lib/hecks/bluebook/dsl/port_builder.rb +2 -6
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +22 -96
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +31 -184
- data/lib/hecks/bluebook/dsl/query_builder.rb +11 -42
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +75 -201
- data/lib/hecks/bluebook/dsl/rule_reference.rb +22 -163
- data/lib/hecks/bluebook/dsl/translation_builder.rb +28 -114
- data/lib/hecks/bluebook/dsl/value_object_builder.rb +10 -124
- data/lib/hecks/bluebook/dsl/word_gate.rb +16 -162
- data/lib/hecks/bluebook/dsl/world_builder.rb +19 -111
- data/lib/hecks/bluebook/entity.rb +13 -71
- data/lib/hecks/bluebook/expression/ast_json.rb +24 -190
- data/lib/hecks/bluebook/expression/ast_reader.rb +12 -54
- data/lib/hecks/bluebook/expression/canonical_form.rb +11 -88
- data/lib/hecks/bluebook/expression/evaluator.rb +47 -295
- data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +30 -147
- data/lib/hecks/bluebook/expression/resolver.rb +37 -660
- data/lib/hecks/bluebook/hexagon.rb +5 -28
- data/lib/hecks/bluebook/lifecycle.rb +5 -16
- data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +1 -7
- data/lib/hecks/bluebook/meta_validator/judge.rb +111 -361
- data/lib/hecks/bluebook/meta_validator/plan.rb +33 -162
- data/lib/hecks/bluebook/meta_validator/port_judge.rb +2 -7
- data/lib/hecks/bluebook/meta_validator/readings.rb +56 -342
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +24 -176
- data/lib/hecks/bluebook/meta_validator/shapes.rb +53 -267
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +43 -305
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +7 -43
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +6 -18
- data/lib/hecks/bluebook/meta_validator.rb +79 -349
- data/lib/hecks/bluebook/model_check/client_profile.rb +6 -21
- data/lib/hecks/bluebook/model_check.rb +100 -597
- data/lib/hecks/bluebook/pattern_subset.rb +11 -69
- data/lib/hecks/bluebook/process_manager.rb +13 -83
- data/lib/hecks/bluebook/project_loader.rb +3 -39
- data/lib/hecks/bluebook/project_register.rb +12 -60
- data/lib/hecks/bluebook/query.rb +9 -48
- data/lib/hecks/bluebook/read_model.rb +57 -81
- data/lib/hecks/bluebook/reference.rb +7 -41
- data/lib/hecks/bluebook/smoke_test.rb +8 -98
- data/lib/hecks/bluebook/synthesizer.rb +13 -62
- data/lib/hecks/bluebook/translation.rb +26 -62
- data/lib/hecks/bluebook/value_object.rb +11 -36
- data/lib/hecks/bluebook.rb +5 -22
- data/lib/hecks/cache_dir.rb +4 -23
- data/lib/hecks/cli/document.rb +3 -21
- data/lib/hecks/cli/ir.rb +3 -5
- data/lib/hecks/cli/mcp.rb +2 -3
- data/lib/hecks/cli/mcp_door.rb +19 -114
- data/lib/hecks/cli/model_check.rb +18 -85
- data/lib/hecks/cli/project_cli.rb +7 -40
- data/lib/hecks/cli/project_diagrams.rb +0 -7
- data/lib/hecks/cli/run.rb +12 -58
- data/lib/hecks/cli/smoke_test.rb +2 -16
- data/lib/hecks/cli/stores.rb +1 -2
- data/lib/hecks/cli.rb +18 -24
- data/lib/hecks/construct.rb +8 -34
- data/lib/hecks/deploy/bluebook/deploy.bluebook +33 -142
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +2 -9
- data/lib/hecks/embryonaut_bluebook/lock.rb +3 -35
- data/lib/hecks/embryonaut_bluebook/shape.rb +1 -17
- data/lib/hecks/embryonaut_bluebook/vendor.rb +8 -53
- data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +4 -19
- data/lib/hecks/embryonaut_bluebook.rb +4 -71
- data/lib/hecks/facade/cli_door.rb +24 -106
- data/lib/hecks/facade/cli_runner.rb +28 -172
- data/lib/hecks/facade/command_request.rb +13 -33
- data/lib/hecks/facade/handle.rb +34 -121
- data/lib/hecks/facade/json_door.rb +31 -146
- data/lib/hecks/facade/surface/aggregate_door.rb +39 -104
- data/lib/hecks/facade/surface/chapter.rb +10 -54
- data/lib/hecks/facade/surface.rb +8 -23
- data/lib/hecks/facade.rb +10 -25
- data/lib/hecks/forms/app.rb +15 -70
- data/lib/hecks/forms/command_form_renderer.rb +10 -29
- data/lib/hecks/forms/field_renderer.rb +17 -156
- data/lib/hecks/forms/field_shape.rb +30 -222
- data/lib/hecks/forms/html.rb +12 -47
- data/lib/hecks/forms/index_renderer.rb +1 -4
- data/lib/hecks/forms/page.rb +3 -9
- data/lib/hecks/forms/params.rb +14 -60
- data/lib/hecks/forms/port_argument.rb +2 -19
- data/lib/hecks/forms/query_form_renderer.rb +4 -79
- data/lib/hecks/forms/record_renderer.rb +8 -75
- data/lib/hecks/forms/record_table.rb +7 -50
- data/lib/hecks/forms/reference_options.rb +3 -30
- data/lib/hecks/forms/value_object_shape.rb +8 -35
- data/lib/hecks/forms.rb +5 -33
- data/lib/hecks/fqn.rb +5 -12
- data/lib/hecks/framework/bluebook/compliance.bluebook +16 -65
- data/lib/hecks/framework/bluebook/console_settings.bluebook +47 -134
- data/lib/hecks/framework/bluebook/framework.hecksagon +5 -14
- data/lib/hecks/framework/bluebook/governance.bluebook +13 -34
- data/lib/hecks/framework/bluebook/identity.bluebook +11 -26
- data/lib/hecks/framework/bluebook/privacy.bluebook +8 -33
- data/lib/hecks/framework.rb +11 -80
- data/lib/hecks/freezer.rb +5 -39
- data/lib/hecks/grammar/expression.bluebook +15 -40
- data/lib/hecks/grammar/translation.bluebook +3 -6
- data/lib/hecks/grammar.rb +16 -41
- data/lib/hecks/ir.rb +14 -83
- data/lib/hecks/language/adapter.bluebook +2 -7
- data/lib/hecks/language/bluebook/aggregate.bluebook +56 -221
- data/lib/hecks/language/bluebook/attaches/paging.bluebook +6 -15
- data/lib/hecks/language/bluebook/bluebook.bluebook +15 -83
- data/lib/hecks/language/bluebook/bluebook.hecksagon +3 -15
- data/lib/hecks/language/bluebook/command.bluebook +27 -144
- data/lib/hecks/language/bluebook/entity.bluebook +24 -97
- data/lib/hecks/language/bluebook/policy.bluebook +16 -67
- data/lib/hecks/language/bluebook/process_manager.bluebook +28 -151
- data/lib/hecks/language/bluebook/projection.bluebook +126 -60
- data/lib/hecks/language/bluebook/query.bluebook +12 -43
- data/lib/hecks/language/bluebook/shape.bluebook +23 -73
- data/lib/hecks/language/bluebook/syntax.bluebook +51 -262
- data/lib/hecks/language/bluebook/vocabulary.bluebook +82 -197
- data/lib/hecks/language/hecksagon/adapter_binding.bluebook +3 -10
- data/lib/hecks/language/hecksagon/domain_port.bluebook +4 -19
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +6 -25
- data/lib/hecks/language/hecksagon/port_operation.bluebook +3 -14
- data/lib/hecks/language/oidc.json +35 -0
- data/lib/hecks/language/port.bluebook +4 -17
- data/lib/hecks/language/translation/translation.bluebook +3 -9
- data/lib/hecks/language/translation/translation_aggregate.bluebook +5 -24
- data/lib/hecks/language/world/wiring.bluebook +1 -3
- data/lib/hecks/language/world/world.bluebook +5 -7
- data/lib/hecks/literal.rb +8 -50
- data/lib/hecks/mcp_door_scope.rb +9 -90
- data/lib/hecks/mcp_stdio_guard.rb +4 -41
- data/lib/hecks/naming.rb +29 -137
- data/lib/hecks/ports/access_control.rb +7 -23
- data/lib/hecks/ports/agent/answers.rb +5 -23
- data/lib/hecks/ports/agent.rb +47 -172
- data/lib/hecks/ports/authentication.rb +12 -39
- data/lib/hecks/ports/authorization.rb +16 -43
- data/lib/hecks/ports/clock.rb +5 -45
- data/lib/hecks/ports/extraction.rb +7 -12
- data/lib/hecks/ports/identity_assignment.rb +8 -29
- data/lib/hecks/ports/identity_generation.rb +6 -29
- data/lib/hecks/ports/identity_resolution.rb +2 -10
- data/lib/hecks/ports/key_vault.rb +9 -45
- data/lib/hecks/ports/persistence/append_only.rb +61 -64
- data/lib/hecks/ports/persistence/binding_policy.rb +4 -14
- data/lib/hecks/ports/persistence/codec_boundary.rb +20 -80
- data/lib/hecks/ports/persistence/null_saga_store.rb +4 -20
- data/lib/hecks/ports/persistence/plugin.rb +13 -42
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +38 -141
- data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +15 -115
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +16 -84
- data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +6 -25
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -22
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +55 -181
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/events.rb +77 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +12 -122
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +28 -128
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +73 -474
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +31 -124
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +45 -219
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +13 -108
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +18 -75
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +9 -49
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +6 -123
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +23 -55
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -72
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +7 -16
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +14 -115
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +13 -45
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +127 -554
- data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +13 -63
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +2 -17
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +3 -14
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +13 -58
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -17
- data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +9 -26
- data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +9 -25
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +18 -124
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +34 -123
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +5 -12
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +3 -9
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -14
- data/lib/hecks/ports/persistence/plugins/era.rb +4 -23
- data/lib/hecks/ports/persistence/postgres_dump/connection.rb +7 -23
- data/lib/hecks/ports/persistence/postgres_dump.rb +4 -35
- data/lib/hecks/ports/persistence/remote_runtime.rb +2 -24
- data/lib/hecks/ports/persistence/repository_factory.rb +8 -27
- data/lib/hecks/ports/persistence/state_codec.rb +23 -103
- data/lib/hecks/ports/persistence.rb +7 -14
- data/lib/hecks/ports/projection.rb +42 -84
- data/lib/hecks/ports/query/in_memory.rb +10 -58
- data/lib/hecks/ports/query/ordering.rb +11 -32
- data/lib/hecks/ports/query.rb +14 -37
- data/lib/hecks/ports.rb +2 -8
- data/lib/hecks/projections/bootstrap_table.rb +11 -43
- data/lib/hecks/projections/deploy/fargate/assembly.rb +2 -15
- data/lib/hecks/projections/deploy/fargate/cdn.rb +1 -48
- data/lib/hecks/projections/deploy/fargate/cdn_default.rb +2 -11
- data/lib/hecks/projections/deploy/fargate/check.rb +5 -14
- data/lib/hecks/projections/deploy/fargate/containers.rb +3 -78
- data/lib/hecks/projections/deploy/fargate/extras.rb +3 -44
- data/lib/hecks/projections/deploy/fargate/monitoring.rb +0 -44
- data/lib/hecks/projections/deploy/fargate/settings.rb +2 -54
- data/lib/hecks/projections/deploy/fargate/yaml.rb +13 -58
- data/lib/hecks/projections/deploy/fargate.rb +35 -202
- data/lib/hecks/projections/deploy/lambda.rb +150 -678
- data/lib/hecks/projections/deploy/preview/access_yaml.rb +1 -33
- data/lib/hecks/projections/deploy/preview/containers.rb +6 -111
- data/lib/hecks/projections/deploy/preview/routing_yaml.rb +4 -56
- data/lib/hecks/projections/deploy/preview/script.rb +2 -14
- data/lib/hecks/projections/deploy/preview/settings.rb +3 -33
- data/lib/hecks/projections/deploy/preview/task_yaml.rb +2 -38
- data/lib/hecks/projections/deploy/preview/template.rb +2 -58
- data/lib/hecks/projections/deploy/preview.rb +2 -67
- data/lib/hecks/projections/deploy/scripts/settings.rb +3 -25
- data/lib/hecks/projections/deploy/scripts.rb +4 -57
- data/lib/hecks/projections/deploy/shared.rb +47 -146
- data/lib/hecks/projections/deploy/smoke.rb +4 -61
- data/lib/hecks/projections/deploy/template_diff/comparer.rb +3 -25
- data/lib/hecks/projections/deploy/template_diff/loader.rb +2 -11
- data/lib/hecks/projections/deploy/template_diff.rb +5 -57
- data/lib/hecks/projections/diagrams.rb +20 -582
- data/lib/hecks/projections/glossary/html.rb +14 -130
- data/lib/hecks/projections/glossary/markdown.rb +8 -69
- data/lib/hecks/projections/glossary/mermaid.rb +9 -66
- data/lib/hecks/projections/glossary/sensitivity.rb +5 -34
- data/lib/hecks/projections/glossary/sentences.rb +14 -159
- data/lib/hecks/projections/glossary.rb +30 -222
- data/lib/hecks/projections/ir.rb +3 -10
- data/lib/hecks/projections/model/deviations.rb +15 -42
- data/lib/hecks/projections/model.rb +12 -51
- data/lib/hecks/projections/oidc.rb +12 -56
- data/lib/hecks/projections/parser_table.rb +9 -28
- data/lib/hecks/projections/reference.rb +2 -15
- data/lib/hecks/projections/rust_vocabulary.rb +12 -250
- data/lib/hecks/projections/shape.rb +3 -29
- data/lib/hecks/projections/statements.rb +7 -86
- data/lib/hecks/projections/vocabulary.rb +6 -22
- data/lib/hecks/projections.rb +2 -16
- data/lib/hecks/projector/cli_projector.rb +60 -375
- data/lib/hecks/projector/docs_projector.rb +10 -201
- data/lib/hecks/projector/exporter.rb +42 -134
- data/lib/hecks/projector/ir_projector.rb +2 -9
- data/lib/hecks/projector/narrate_projector.rb +15 -189
- data/lib/hecks/projector/target.rb +13 -76
- data/lib/hecks/projector.rb +30 -121
- data/lib/hecks/query_specification/common/comparators.rb +5 -26
- data/lib/hecks/query_specification/common/comparison.rb +35 -143
- data/lib/hecks/query_specification/common/dsl.rb +8 -24
- data/lib/hecks/query_specification/common/null_policy.rb +20 -70
- data/lib/hecks/query_specification/common/options.rb +2 -6
- data/lib/hecks/query_specification/common/where_clause.rb +2 -10
- data/lib/hecks/query_specification/field_path.rb +37 -77
- data/lib/hecks/query_specification/hop_path.rb +12 -76
- data/lib/hecks/release/gem_pin.rb +2 -26
- data/lib/hecks/release/runner/ci_publisher.rb +3 -6
- data/lib/hecks/release/runner/commands.rb +2 -4
- data/lib/hecks/release/runner/gem_publisher.rb +2 -3
- data/lib/hecks/release/runner/npm_publisher.rb +6 -20
- data/lib/hecks/release/runner/preflight.rb +4 -19
- data/lib/hecks/release/runner/tagger.rb +2 -9
- data/lib/hecks/release/runner.rb +3 -32
- data/lib/hecks/rendering.rb +4 -26
- data/lib/hecks/router/namespace_installer.rb +12 -36
- data/lib/hecks/router.rb +4 -8
- data/lib/hecks/runtime/aggregate_lock.rb +8 -33
- data/lib/hecks/runtime/boot_gates.rb +3 -18
- data/lib/hecks/runtime/caller.rb +19 -59
- data/lib/hecks/runtime/capability_graph.rb +5 -21
- data/lib/hecks/runtime/command_interpreter/argument_gate.rb +16 -78
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +23 -160
- data/lib/hecks/runtime/command_interpreter.rb +124 -306
- data/lib/hecks/runtime/command_rules/admissibility.rb +95 -366
- data/lib/hecks/runtime/command_rules/arithmetic.rb +66 -196
- data/lib/hecks/runtime/command_rules/authorization.rb +13 -45
- data/lib/hecks/runtime/command_rules/emission.rb +8 -21
- data/lib/hecks/runtime/command_rules/references.rb +23 -202
- data/lib/hecks/runtime/dependency_planning.rb +30 -102
- data/lib/hecks/runtime/dispatcher.rb +82 -241
- data/lib/hecks/runtime/entity_element.rb +40 -462
- data/lib/hecks/runtime/entity_interpreter.rb +64 -219
- data/lib/hecks/runtime/errors.rb +32 -115
- data/lib/hecks/runtime/event.rb +3 -25
- data/lib/hecks/runtime/identity.rb +18 -154
- data/lib/hecks/runtime/instance.rb +18 -150
- data/lib/hecks/runtime/interpreting.rb +11 -70
- data/lib/hecks/runtime/invocation.rb +28 -164
- data/lib/hecks/runtime/loader.rb +48 -186
- data/lib/hecks/runtime/outbox.rb +38 -261
- data/lib/hecks/runtime/policy_interpreter.rb +38 -200
- data/lib/hecks/runtime/port_operation_interpreter.rb +26 -87
- data/lib/hecks/runtime/query_interpreter.rb +45 -187
- data/lib/hecks/runtime/reaction_invocation.rb +36 -138
- data/lib/hecks/runtime/read_model_interpreter.rb +117 -208
- data/lib/hecks/runtime/rebuild_sweep.rb +8 -54
- data/lib/hecks/runtime/reference_hop.rb +8 -81
- data/lib/hecks/runtime/refusal_wording.rb +8 -89
- data/lib/hecks/runtime/registry/saga_persistence.rb +10 -102
- data/lib/hecks/runtime/registry/verification.rb +52 -225
- data/lib/hecks/runtime/registry/world_defaults.rb +4 -53
- data/lib/hecks/runtime/registry.rb +99 -244
- data/lib/hecks/runtime/remote_dispatcher.rb +37 -152
- data/lib/hecks/runtime/routing.rb +4 -15
- data/lib/hecks/runtime/saga_interpreter/correlation.rb +14 -61
- data/lib/hecks/runtime/saga_interpreter.rb +79 -263
- data/lib/hecks/runtime/saga_pending_dispatch.rb +2 -43
- data/lib/hecks/runtime/tenant_check.rb +6 -68
- data/lib/hecks/runtime/tenant_scope.rb +3 -28
- data/lib/hecks/runtime/value/admission.rb +16 -67
- data/lib/hecks/runtime/value/coercion.rb +155 -641
- data/lib/hecks/runtime/value/entity_list_coercion.rb +26 -219
- data/lib/hecks/runtime/value.rb +12 -84
- data/lib/hecks/runtime.rb +17 -65
- data/lib/hecks/storehouse.rb +141 -568
- data/lib/hecks/tenancy/bluebook/tenancy.bluebook +8 -32
- data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +6 -23
- data/lib/hecks/vendoring/git_environment.rb +2 -8
- data/lib/hecks/vendoring/git_source.rb +4 -10
- data/lib/hecks/vendoring.rb +7 -46
- data/lib/hecks/version.rb +3 -14
- data/lib/hecks.rb +26 -81
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +8 -46
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +11 -80
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +10 -99
- metadata +3 -2
|
@@ -5,6 +5,7 @@ require_relative "../../../../adapters/driven/postgres/outbox"
|
|
|
5
5
|
require_relative "../../../../adapters/driven/postgres/reconnect"
|
|
6
6
|
require_relative "postgres_era/lineage"
|
|
7
7
|
require_relative "postgres_era/lineage_manager"
|
|
8
|
+
require_relative "postgres_era/events"
|
|
8
9
|
require_relative "../../../../ports/persistence/append_only"
|
|
9
10
|
require_relative "../../../../query_specification/common/order_by"
|
|
10
11
|
require_relative "../../../../runtime/errors"
|
|
@@ -14,100 +15,45 @@ require_relative "../../../../runtime/registry"
|
|
|
14
15
|
|
|
15
16
|
module Hecks
|
|
16
17
|
module Adapters
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# (translate, fork, merge) where every other adapter can only refuse
|
|
20
|
-
# toward it. Sibling to the plain `Postgres` adapter (postgres.rb),
|
|
21
|
-
# which is the same database with none of this machinery — pick
|
|
22
|
-
# PostgresEra only once a domain actually needs to survive a shape
|
|
23
|
-
# change live. See docs/implemented/postgres-era-adapter-split-plan.md for why
|
|
24
|
-
# the two are split and what each one carries.
|
|
25
|
-
#
|
|
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
|
|
31
|
-
# sequence spanning partitions. Appends go there; nothing updates
|
|
32
|
-
# or deletes a journal row (immutability by privilege — UPDATE and
|
|
33
|
-
# DELETE revoked; a deployment's app role connects as a non-owner).
|
|
34
|
-
# - Per aggregate, the head is derived: era 1 reads a plain view
|
|
35
|
-
# (latest save per id); later eras read a view overlaying the
|
|
36
|
-
# materialized, translated ancestor tail with live current-era
|
|
37
|
-
# rows. `project` is therefore a no-op — old entries are never
|
|
38
|
-
# rewritten, and the head is never a table anything writes.
|
|
39
|
-
# - State is one jsonb column. jsonb normalizes key order (and drops
|
|
40
|
-
# duplicate keys), so anything comparing stored state — the corpus
|
|
41
|
-
# history gate above all — must compare canonicalized state, never
|
|
42
|
-
# raw bytes; `bin/canonicalise` deep-sorts keys, which is exactly
|
|
43
|
-
# why the gate survives this normalization.
|
|
44
|
-
# - Query pushdown is the shared SqlQueryBuilder: every declared
|
|
45
|
-
# operator compiles fully into SQL, or the query refuses loudly.
|
|
18
|
+
# Postgres adapter that journals each write per domain and derives every head by era; the
|
|
19
|
+
# only one that can translate, fork or merge on shape drift. Compare jsonb state canonicalized.
|
|
46
20
|
class PostgresEra
|
|
47
21
|
include SqlQueryBuilder
|
|
48
22
|
include Adapters::PostgresOutbox
|
|
49
23
|
include Adapters::PostgresReconnect
|
|
24
|
+
include Events
|
|
50
25
|
|
|
51
26
|
attr_reader :aggregate
|
|
52
27
|
|
|
53
28
|
# Names the optional persistence capabilities this adapter implements natively.
|
|
54
29
|
#
|
|
55
|
-
# `:cross_process_lock`
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
# falls back to. See ADR 0036: that in-process Mutex is invisible
|
|
61
|
-
# to `rust/host` dispatching against the same PostgresEra-bound
|
|
62
|
-
# tables from a separate OS process.
|
|
30
|
+
# `:cross_process_lock` lets dispatch hold `with_write_lock` instead of the in-process
|
|
31
|
+
# mutex, which `rust/host` in another OS process cannot see (ADR 0036). `:atomic_append`
|
|
32
|
+
# tells `RepositoryFactory.build` that `append` (below) already commits the snapshot in
|
|
33
|
+
# the same transaction as the journal row, so replaying the journal through `project` on
|
|
34
|
+
# boot has nothing left to fix up.
|
|
63
35
|
#
|
|
64
|
-
# @return [Array<Symbol>] always `[:atomic_put, :cross_process_lock]`
|
|
65
|
-
def persistence_capabilities = %i[atomic_put cross_process_lock]
|
|
36
|
+
# @return [Array<Symbol>] always `[:atomic_put, :cross_process_lock, :atomic_append]`
|
|
37
|
+
def persistence_capabilities = %i[atomic_put cross_process_lock atomic_append]
|
|
66
38
|
|
|
67
39
|
# Declares that this adapter can act on shape drift rather than only refuse.
|
|
68
40
|
#
|
|
69
|
-
# The capability idiom: only PostgresEra answers true, and only
|
|
70
|
-
# PostgresEra carries an era_check! for the boot gate to delegate to.
|
|
71
|
-
#
|
|
72
41
|
# @return [Boolean] always true
|
|
73
42
|
def self.lineage_capable? = true
|
|
74
43
|
|
|
75
|
-
# Declares that two tenant boots
|
|
76
|
-
#
|
|
77
|
-
# **Tenant-capable** — see Runtime::TenantCheck's own header for the
|
|
78
|
-
# full reasoning. `connect_for`'s own `schema:` setting (the
|
|
79
|
-
# Storehouse shared-instance mechanism, already built, already
|
|
80
|
-
# proven for eras) is what keeps two tenant boots' tables apart:
|
|
81
|
-
# each boot's own `SET search_path` means every unqualified
|
|
82
|
-
# reference this adapter and its lineage classes construct
|
|
83
|
-
# resolves into that boot's own schema, never another tenant's —
|
|
84
|
-
# proven for real, not assumed, by tenant_isolation_spec.rb, the
|
|
85
|
-
# same discipline lineage_capable? already holds itself to.
|
|
44
|
+
# Declares that two tenant boots on separate `schema:` settings keep their tables apart.
|
|
86
45
|
#
|
|
87
46
|
# @return [Boolean] always true
|
|
88
47
|
def self.tenant_capable? = true
|
|
89
48
|
|
|
90
|
-
# Resolves
|
|
91
|
-
#
|
|
92
|
-
# `LineageManager.check!` (see `LineageManager::EraResolver`).
|
|
49
|
+
# Resolves this boot's era, minting the next one when the shape drifted and a translation
|
|
50
|
+
# edge covers it. Delegates to `LineageManager.check!`.
|
|
93
51
|
#
|
|
94
|
-
# @param
|
|
95
|
-
#
|
|
96
|
-
# @
|
|
97
|
-
# @
|
|
98
|
-
# @
|
|
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
|
|
52
|
+
# @param settings [Hash{Symbol, String => Object}] persistence settings; needs `database`
|
|
53
|
+
# @param directory [String, nil] where `HECKS_SCAFFOLD=1` writes a translation edge
|
|
54
|
+
# @return [Integer, nil] the ordinal of the era this boot minted, or nil when none
|
|
55
|
+
# @raise [Runtime::WiringError] if the database is unreachable or the mint refuses
|
|
56
|
+
# @raise [Bluebook::DSL::Malformed] if a held era's text parses under no grammar
|
|
111
57
|
def self.era_check!(registry:, bluebook:, current_text:, settings:, directory: nil)
|
|
112
58
|
LineageManager.check!(
|
|
113
59
|
registry: registry, bluebook: bluebook, current_text: current_text,
|
|
@@ -115,23 +61,11 @@ module Hecks
|
|
|
115
61
|
)
|
|
116
62
|
end
|
|
117
63
|
|
|
118
|
-
# Reads one setting
|
|
64
|
+
# Reads one setting under either its Symbol or String spelling.
|
|
119
65
|
#
|
|
120
|
-
#
|
|
121
|
-
# may arrive symbol-keyed (built straight in Ruby) or string-keyed
|
|
122
|
-
# (round-tripped through JSON), and a domain that names one is not
|
|
123
|
-
# obligated to also skip the other. `key?` decides which spelling
|
|
124
|
-
# actually exists, never `||` — `||` cannot tell a genuinely stored
|
|
125
|
-
# `false` apart from an absent key, and would silently prefer the
|
|
126
|
-
# other spelling (or `default`) instead of returning the real, held
|
|
127
|
-
# answer. See Hecks::QuerySpecification::FieldPath#read for the same
|
|
128
|
-
# discipline applied to stored state instead of settings.
|
|
66
|
+
# Uses `key?` rather than `||` so a stored `false` is not mistaken for an absent key.
|
|
129
67
|
#
|
|
130
|
-
# @
|
|
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
|
|
68
|
+
# @return [Object, nil] the stored value, or `default` when neither spelling is present
|
|
135
69
|
def self.setting(settings, key, default: nil)
|
|
136
70
|
return settings[key] if settings.key?(key)
|
|
137
71
|
|
|
@@ -141,20 +75,15 @@ module Hecks
|
|
|
141
75
|
default
|
|
142
76
|
end
|
|
143
77
|
|
|
144
|
-
# Opens a connection to the database
|
|
145
|
-
#
|
|
146
|
-
# connection and closes it.
|
|
78
|
+
# Opens a connection to the declared database, selecting the declared `schema` if any.
|
|
79
|
+
# The caller owns the connection and closes it.
|
|
147
80
|
#
|
|
148
81
|
# @param name [String] the domain or aggregate name, used only in refusal messages
|
|
149
|
-
# @param settings [Hash{Symbol, String => Object}]
|
|
150
|
-
#
|
|
151
|
-
# @
|
|
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`
|
|
82
|
+
# @param settings [Hash{Symbol, String => Object}] `database` is a name or `postgres://` URL
|
|
83
|
+
# @return [PG::Connection] an open connection
|
|
84
|
+
# @raise [Runtime::WiringError] if `database` is missing or Postgres refuses a statement
|
|
155
85
|
def self.connect_for(name, settings)
|
|
156
|
-
#
|
|
157
|
-
# a domain that never wires PostgresEra should never need the gem.
|
|
86
|
+
# Lazy so a domain that never wires PostgresEra does not need the pg gem.
|
|
158
87
|
require "pg"
|
|
159
88
|
|
|
160
89
|
declared = setting(settings, :database)
|
|
@@ -171,39 +100,17 @@ module Hecks
|
|
|
171
100
|
PG.connect(dbname: declared)
|
|
172
101
|
end
|
|
173
102
|
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
# storehouse) — every unqualified table/view/function reference
|
|
177
|
-
# this adapter and its lineage classes ever construct resolves
|
|
178
|
-
# through search_path, so this one SET is what makes ALTER
|
|
179
|
-
# TABLE ... SET SCHEMA migrations transparent to the rest of the
|
|
180
|
-
# adapter. A domain with no `schema` setting keeps Postgres's
|
|
181
|
-
# own default search_path (public).
|
|
103
|
+
# A declared `schema` means the instance is shared; search_path makes every unqualified
|
|
104
|
+
# name this adapter and its lineage classes build resolve inside that schema.
|
|
182
105
|
schema = setting(settings, :schema)
|
|
183
106
|
if schema.to_s != ""
|
|
184
|
-
#
|
|
185
|
-
# naming a `schema:` nobody has created yet fails on its first
|
|
186
|
-
# table-creation attempt with Postgres's own "no schema has been
|
|
187
|
-
# selected to create in", found live provisioning tenant_
|
|
188
|
-
# isolation_spec.rb's own multi-schema fixture by hand.
|
|
189
|
-
# `CREATE SCHEMA IF NOT EXISTS` is exactly the
|
|
190
|
-
# same self-healing idempotency this adapter's own table/era
|
|
191
|
-
# provisioning already holds itself to (see the comment right
|
|
192
|
-
# below on `client_min_messages`) — a schema that already
|
|
193
|
-
# exists is the ordinary case for every boot after the first,
|
|
194
|
-
# not news.
|
|
107
|
+
# Idempotent: the schema may not exist yet on the first boot.
|
|
195
108
|
connection.exec("CREATE SCHEMA IF NOT EXISTS #{connection.quote_ident(schema)}")
|
|
196
109
|
connection.exec("SET search_path TO #{connection.quote_ident(schema)}")
|
|
197
110
|
end
|
|
198
111
|
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
# already exists is the ordinary case, not news, and Postgres
|
|
202
|
-
# surfaces every one as a notice by default. `bin/set-password`
|
|
203
|
-
# boots a real registry just to mint an Identity, and nobody
|
|
204
|
-
# setting a password needs to see a page of "relation ...
|
|
205
|
-
# already exists, skipping" to do it. Warning and above (real
|
|
206
|
-
# problems) still surface.
|
|
112
|
+
# Provisioning re-runs CREATE ... IF NOT EXISTS on every boot; silence the notices.
|
|
113
|
+
# Warnings and above still surface.
|
|
207
114
|
connection.exec("SET client_min_messages = warning")
|
|
208
115
|
connection
|
|
209
116
|
rescue PG::Error => e
|
|
@@ -211,118 +118,51 @@ module Hecks
|
|
|
211
118
|
"cannot bind PostgresEra at #{declared} for #{name}: #{e.message.strip}"
|
|
212
119
|
end
|
|
213
120
|
|
|
214
|
-
# Connects
|
|
215
|
-
#
|
|
121
|
+
# Connects and provisions the journal, this aggregate's head and field caches, and the
|
|
122
|
+
# event, saga and outbox tables. Every step is idempotent.
|
|
216
123
|
#
|
|
217
|
-
# @param
|
|
218
|
-
#
|
|
219
|
-
#
|
|
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
|
|
124
|
+
# @param settings [Hash{Symbol, String => Object}] world settings plus what
|
|
125
|
+
# `RepositoryFactory.build` merges in: `domain`, `era` and `superseded_by`
|
|
126
|
+
# @raise [Runtime::WiringError] if `database` is missing or the connection is refused
|
|
225
127
|
def initialize(aggregate:, settings: {}, root: nil)
|
|
226
128
|
@aggregate = aggregate
|
|
227
129
|
@settings = settings
|
|
228
130
|
@db = self.class.connect_for(aggregate.name, settings)
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
# (`RepositoryFactory.build`'s own `domain` positional arg —
|
|
232
|
-
# `bluebook.name`, confirmed at `outbox.rb`'s own call site —
|
|
233
|
-
# never this default in real dispatch). This default exists only
|
|
234
|
-
# for a directly-instantiated adapter (specs, consoles) that
|
|
235
|
-
# skips the factory.
|
|
236
|
-
#
|
|
237
|
-
# When the aggregate does have an owning chapter (`hecks_owner`
|
|
238
|
-
# set — true for any aggregate sealed through a real bluebook,
|
|
239
|
-
# even if this adapter itself was built by hand), default to the
|
|
240
|
-
# chapter's own declared PascalCase name. That's the same string
|
|
241
|
-
# `rust/host`'s `pg_advisory_xact_lock` key derives from
|
|
242
|
-
# (`HECKS_DOMAIN`, deploy-time-set to `world.domain`) — the
|
|
243
|
-
# aggregate's own snake_case `storage_name` silently disagreed
|
|
244
|
-
# with it (`hashtext` is byte-sensitive: `"chess"` and `"Chess"`
|
|
245
|
-
# never collide), which is ADR 0036 Blocker 1.
|
|
246
|
-
#
|
|
247
|
-
# When there's no owning chapter at all (a bare
|
|
248
|
-
# `AggregateBuilder` fixture built outside any real bluebook —
|
|
249
|
-
# `hecks_owner` is only ever stamped by chapter construction, see
|
|
250
|
-
# `traits.rb`'s `hecks_owner = self`) there is no chapter name to
|
|
251
|
-
# match Rust against in the first place, so fall back to the
|
|
252
|
-
# aggregate's own name.
|
|
131
|
+
# Journal name: the owning bluebook's declared name, matching the key rust/host derives its
|
|
132
|
+
# advisory lock from (ADR 0036). A bare aggregate with no owner falls back to its own name.
|
|
253
133
|
@domain = self.class.setting(
|
|
254
134
|
settings, :domain, default: aggregate.hecks_owner&.name || aggregate.storage_name
|
|
255
135
|
).to_s
|
|
256
136
|
@lineage = Lineage.new(@db, @domain)
|
|
257
137
|
@lineage.ensure_base!
|
|
258
|
-
#
|
|
259
|
-
#
|
|
260
|
-
# partition); a directly-instantiated adapter defaults to the
|
|
261
|
-
# newest.
|
|
262
|
-
#
|
|
263
|
-
# Not `self.class.setting(...)` here — RepositoryFactory#build
|
|
264
|
-
# always merges `era: registry.resolved_eras[domain]` into
|
|
265
|
-
# settings, so the key is genuinely present (not absent) for
|
|
266
|
-
# any domain the era boot gate hasn't resolved yet (or that
|
|
267
|
-
# doesn't have one at all), just holding `nil`. `setting`'s own
|
|
268
|
-
# presence-over-truthiness discipline (correct for a field like
|
|
269
|
-
# `:role`, where a stored `false` is a real, distinct answer
|
|
270
|
-
# from "unset") does the wrong thing for `:era` specifically:
|
|
271
|
-
# `nil` can never be a meaningful era override — only a real
|
|
272
|
-
# ordinal or "not resolved, self-resolve" ever apply — so
|
|
273
|
-
# coalescing here, not `setting`'s presence check, is what
|
|
274
|
-
# actually honors this comment's own "defaults to the newest"
|
|
275
|
-
# promise.
|
|
138
|
+
# Coalesce instead of `setting`: the factory always passes `era:`, nil until the boot gate
|
|
139
|
+
# resolves it, and nil can never be a real override.
|
|
276
140
|
@era = settings.key?(:era) ? settings[:era] : settings["era"]
|
|
277
141
|
@era ||= @lineage.current_era
|
|
278
|
-
#
|
|
279
|
-
#
|
|
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.
|
|
142
|
+
# Non-nil only for a held-but-superseded boot. `append` and `atomic_put` refuse on it
|
|
143
|
+
# because a superuser walks through the RLS fence.
|
|
291
144
|
@superseded_by = settings.key?(:superseded_by) ? settings[:superseded_by] : settings["superseded_by"]
|
|
292
|
-
#
|
|
293
|
-
# suspenders self-healing (compile_head! already ensures this for
|
|
294
|
-
# a freshly-minted era's own name; ensure_first_head! for era 1's)
|
|
295
|
-
# against any boot-ordering surprise, at the cost of one
|
|
296
|
-
# CREATE TABLE IF NOT EXISTS nobody pays for twice.
|
|
145
|
+
# Idempotent self-healing against any boot-ordering surprise.
|
|
297
146
|
@lineage.ensure_head_snapshot!(table, @era)
|
|
298
147
|
@lineage.ensure_first_head!(table) if @era == 1
|
|
299
|
-
#
|
|
300
|
-
#
|
|
301
|
-
# table per `where`-field this aggregate's own declared queries
|
|
302
|
-
# (and its entities' own) actually use, derived automatically
|
|
303
|
-
# (principle 3 — no bluebook keyword), self-healing and
|
|
304
|
-
# idempotent like everything else booted here. `@field_caches`
|
|
305
|
-
# maps field -> cache-table name; `query` below consults it to
|
|
306
|
-
# decide whether a declared query can skip the DISTINCT ON
|
|
307
|
-
# reduction entirely.
|
|
148
|
+
# One row-cache table per where-field the aggregate's queries use; `query` consults it
|
|
149
|
+
# to skip the head reduction.
|
|
308
150
|
@field_caches = ensure_field_caches!
|
|
309
151
|
create_event_table!
|
|
310
152
|
create_saga_table!
|
|
311
153
|
create_outbox_table!
|
|
312
154
|
end
|
|
313
155
|
|
|
314
|
-
# Names this aggregate in storage: the journal's `aggregate`
|
|
315
|
-
#
|
|
156
|
+
# Names this aggregate in storage: the journal's `aggregate` value and the stem of its
|
|
157
|
+
# head view, head snapshot and field-cache names.
|
|
316
158
|
#
|
|
317
159
|
# @return [String] the aggregate's snake_case storage name
|
|
318
160
|
def table = @aggregate.storage_name
|
|
319
161
|
|
|
320
|
-
# Reads one record's current state through the
|
|
321
|
-
# written under an ancestor era comes back translated to the current shape.
|
|
162
|
+
# Reads one record's current state through the head view, translated to the current shape.
|
|
322
163
|
#
|
|
323
164
|
# @param id [String, Object] the record's identity, compared as `id.to_s`
|
|
324
|
-
# @return [Runtime::Instance, nil] the
|
|
325
|
-
# saved row for that id (never written, or deleted)
|
|
165
|
+
# @return [Runtime::Instance, nil] the record, or nil when never written or deleted
|
|
326
166
|
def find(id)
|
|
327
167
|
result = @db.exec_params(%(SELECT id, state FROM #{quoted_head} WHERE id = $1), [id.to_s])
|
|
328
168
|
return nil if result.ntuples.zero?
|
|
@@ -332,21 +172,12 @@ module Hecks
|
|
|
332
172
|
|
|
333
173
|
# Lists every current record, ordered by id unless the caller names a field.
|
|
334
174
|
#
|
|
335
|
-
# order_by is a runtime value,
|
|
336
|
-
#
|
|
337
|
-
# HTTP request, in the console's case. Whitelisted against the
|
|
338
|
-
# aggregate's own real attributes (plus its lifecycle field) before
|
|
339
|
-
# it ever reaches order_expression, unlike a declared query's
|
|
340
|
-
# order_by, which the language itself already only lets name a real
|
|
341
|
-
# attribute at parse time. Without this, an unknown field wouldn't
|
|
342
|
-
# error — query_expression degrades a nil attribute to a harmless
|
|
343
|
-
# no-op path — it would just silently sort by nothing.
|
|
175
|
+
# `order_by` is a runtime value (an HTTP param), so it is checked against the aggregate's
|
|
176
|
+
# attributes; an unknown field would otherwise sort by nothing without erroring.
|
|
344
177
|
#
|
|
345
|
-
# @param order_by [String, Symbol, nil] an attribute
|
|
346
|
-
#
|
|
347
|
-
# @
|
|
348
|
-
# else ascending; nulls sort first ascending and last descending
|
|
349
|
-
# @return [Array<Runtime::Instance>] every saved record, `[]` when there are none
|
|
178
|
+
# @param order_by [String, Symbol, nil] an attribute, the lifecycle field, or a dotted path
|
|
179
|
+
# @param direction [Symbol, String] `desc` sorts descending; nulls sort first ascending
|
|
180
|
+
# @return [Array<Runtime::Instance>] every saved record
|
|
350
181
|
# @raise [Runtime::WiringError] if `order_by` names no attribute of this aggregate
|
|
351
182
|
def all(order_by: nil, direction: :asc)
|
|
352
183
|
return @db.exec(%(SELECT id, state FROM #{quoted_head} ORDER BY id)).map { |row| instance(row) } unless order_by
|
|
@@ -366,51 +197,16 @@ module Hecks
|
|
|
366
197
|
# @return [Integer] how many saved, undeleted records the head holds
|
|
367
198
|
def count = @db.exec(%(SELECT COUNT(*) FROM #{quoted_head}))[0]["count"].to_i
|
|
368
199
|
|
|
369
|
-
# Runs a declared query, looking candidate ids up in the field caches
|
|
370
|
-
#
|
|
200
|
+
# Runs a declared query, looking candidate ids up in the field caches when every `where`
|
|
201
|
+
# clause allows it and only then reading those ids from the head view.
|
|
371
202
|
#
|
|
372
|
-
# The
|
|
373
|
-
#
|
|
374
|
-
# principle 2) always runs correctly here — it filters against
|
|
375
|
-
# `head_view`, which is already the fully-reduced current state —
|
|
376
|
-
# but for a domain that has minted a second era, that reduction
|
|
377
|
-
# itself is the expensive part, and no index on the jsonb path
|
|
378
|
-
# changes that (see field_cache.rb's own header for the SQL-
|
|
379
|
-
# semantics reason why). When every `where` clause this query
|
|
380
|
-
# declares either targets a cached field or is eligible to (an
|
|
381
|
-
# ordinary comparator, not a null-vs-value special case — see
|
|
382
|
-
# `cache_eligible?`), skip the reduction: look candidate ids up in
|
|
383
|
-
# the cache table(s) first (cheap, indexed, no reduction involved),
|
|
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
|
|
387
|
-
# comparison) is simply re-checked against `head_view` in the
|
|
388
|
-
# second phase, exactly as `super` would have checked it anyway —
|
|
389
|
-
# this can only ever narrow what phase two has to look at, never
|
|
390
|
-
# change what a clause means.
|
|
391
|
-
#
|
|
392
|
-
# Falls back to `super` whenever no clause can be accelerated — a
|
|
393
|
-
# query with no `where` at all (order_by-only — no cache table
|
|
394
|
-
# exists for these, see field_cache.rb), a query whose only clauses
|
|
395
|
-
# target fields with no cache table, or a domain that has never
|
|
396
|
-
# minted a second era at all (`@field_caches` is never empty just
|
|
397
|
-
# because era 1 has no reduction to skip — the cache tables still
|
|
398
|
-
# exist and still accelerate era 1 the same way, but the fallback
|
|
399
|
-
# path is already just as cheap there since head_view is the
|
|
400
|
-
# snapshot table verbatim for era 1; skipping straight to `super`
|
|
401
|
-
# in that case would be a valid future optimization, not attempted
|
|
402
|
-
# here to keep this one code path correct for every era uniformly).
|
|
203
|
+
# The cache only narrows candidates; the head view stays authoritative, so results match
|
|
204
|
+
# `super`. Uncached or null-comparison clauses are re-checked against the head view.
|
|
403
205
|
#
|
|
404
206
|
# @param declared [Bluebook::Query] the declared query, or a delegator wrapping one
|
|
405
|
-
#
|
|
406
|
-
# @
|
|
407
|
-
#
|
|
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
|
|
207
|
+
# @param args [Hash{Symbol => Object}] caller-supplied values for named arguments
|
|
208
|
+
# @return [Array<Runtime::Instance>] the matching records, by id when no order is declared
|
|
209
|
+
# @raise [ArgumentError] if a clause uses an operator this dialect cannot compile
|
|
414
210
|
def query(declared, args = {}, context: {})
|
|
415
211
|
return super if @field_caches.empty? || declared.wheres.empty?
|
|
416
212
|
|
|
@@ -424,59 +220,27 @@ module Hecks
|
|
|
424
220
|
head_phase(declared, uncached, ids, args)
|
|
425
221
|
end
|
|
426
222
|
|
|
427
|
-
# Runs a block
|
|
223
|
+
# Runs a block in one transaction holding the domain's cross-process write lock (ADR 0036).
|
|
428
224
|
#
|
|
429
|
-
#
|
|
430
|
-
#
|
|
431
|
-
# `append`'s own transaction below: `lock_writes!` has to be held
|
|
432
|
-
# before hydrate even starts, or two cross-process writers can
|
|
433
|
-
# both hydrate unlocked and race for the write, each blind to the
|
|
434
|
-
# other. `transaction` (via `include Adapters::PostgresOutbox`) is
|
|
435
|
-
# already re-entrant — `append`/`atomic_put`'s own inner
|
|
436
|
-
# `transaction do ... end`, deep inside the block below, joins
|
|
437
|
-
# this same transaction instead of opening/committing its own, so
|
|
438
|
-
# the advisory lock stays held until this whole block returns.
|
|
225
|
+
# The lock must be taken before hydrate, so it wraps the whole dispatch order; the
|
|
226
|
+
# nested `append` joins this transaction and the lock is held until the block returns.
|
|
439
227
|
#
|
|
440
|
-
# @yield the dispatch order
|
|
441
|
-
# transaction back
|
|
442
|
-
# @yieldreturn [Object] whatever the dispatch order produces
|
|
228
|
+
# @yield the dispatch order; an exception rolls the transaction back
|
|
443
229
|
# @return [Object] the block's own value
|
|
444
|
-
# @raise [PG::ConnectionBad] if the connection dropped;
|
|
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
|
|
230
|
+
# @raise [PG::ConnectionBad] if the connection dropped; it reconnects, then re-raises
|
|
448
231
|
def with_write_lock(&block) = transaction { lock_writes!; block.call } # rubocop:disable Style/Semicolon
|
|
449
232
|
|
|
450
|
-
# Appends one entry to the journal and
|
|
451
|
-
#
|
|
233
|
+
# Appends one entry to the journal and updates the head snapshot and every field cache,
|
|
234
|
+
# all in one transaction under the domain's write lock.
|
|
452
235
|
#
|
|
453
|
-
# The lock
|
|
454
|
-
#
|
|
455
|
-
#
|
|
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
|
|
459
|
-
# for why only that half of the race is closed.
|
|
460
|
-
# The journal insert and the snapshot upsert/delete happen in the
|
|
461
|
-
# same transaction — real ACID atomicity, not the append-then-
|
|
462
|
-
# project two-step a file-based adapter needs a crash-recovery
|
|
463
|
-
# replay for (see Heki). If this transaction commits, the snapshot
|
|
464
|
-
# is already exactly as current as the journal; if it doesn't,
|
|
465
|
-
# neither happened. `project` stays uninvolved on purpose — it
|
|
466
|
-
# still runs, cheaply, during AppendOnly#recover!'s full replay on
|
|
467
|
-
# every boot (see `project` below), and a second write there would
|
|
468
|
-
# make that replay pay real DB cost for a snapshot that's already
|
|
469
|
-
# correct. When `with_write_lock` above already drove this whole
|
|
470
|
-
# dispatch, `lock_writes!` here is a harmless re-acquire of the
|
|
471
|
-
# same already-held (per-session-reentrant) advisory lock; a bare
|
|
472
|
-
# `repository.save` outside a full dispatch still takes it fresh.
|
|
236
|
+
# The lock precedes the INSERT because the ordinal comes from `nextval()` inside it. It is
|
|
237
|
+
# a different key from `mint_era!`'s, so it serializes writes, never a write against a mint.
|
|
238
|
+
# `project` stays out of it: AppendOnly#recover! already replays it on every boot.
|
|
473
239
|
#
|
|
474
|
-
# @param entry [Ports::Persistence::Entry] the save or delete to journal
|
|
475
|
-
# is decoded domain state, encoded through `StateCodec` on the way in
|
|
240
|
+
# @param entry [Ports::Persistence::Entry] the save or delete to journal
|
|
476
241
|
# @return [Ports::Persistence::Entry] the same entry, unchanged
|
|
477
242
|
# @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
|
|
243
|
+
# @raise [PG::Error] if Postgres refuses a statement, such as the era fence's RLS
|
|
480
244
|
def append(entry)
|
|
481
245
|
refuse_superseded_write!
|
|
482
246
|
transaction do
|
|
@@ -487,16 +251,10 @@ module Hecks
|
|
|
487
251
|
end
|
|
488
252
|
|
|
489
253
|
# 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.
|
|
254
|
+
# Runs before the transaction so a stale checkout takes no lock; reads stay allowed.
|
|
496
255
|
#
|
|
497
256
|
# @return [nil] when this boot's era is current
|
|
498
257
|
# @raise [Runtime::WiringError] if the boot gate marked this era superseded
|
|
499
|
-
# (`superseded_by` in the settings)
|
|
500
258
|
def refuse_superseded_write!
|
|
501
259
|
return unless @superseded_by
|
|
502
260
|
|
|
@@ -507,19 +265,12 @@ module Hecks
|
|
|
507
265
|
"reboot to write again."
|
|
508
266
|
end
|
|
509
267
|
|
|
510
|
-
# Saves an entry and reports whether it inserted or replaced,
|
|
511
|
-
#
|
|
512
|
-
#
|
|
513
|
-
# Outcome detection, journal append and every derived projection share
|
|
514
|
-
# the same transaction and domain write lock. The lineage-aware head
|
|
515
|
-
# determines whether this id is already visible; no repository `find`
|
|
516
|
-
# occurs before entering this adapter-native operation.
|
|
268
|
+
# Saves an entry and reports whether it inserted or replaced, decided under the same lock
|
|
269
|
+
# that guards the write.
|
|
517
270
|
#
|
|
518
271
|
# @param entry [Ports::Persistence::Entry] the save to journal
|
|
519
|
-
# @param insert_only [Boolean] when true, an id the head
|
|
520
|
-
#
|
|
521
|
-
# @return [Symbol] `:inserted`, `:replaced`, or `:conflicted` when `insert_only` met
|
|
522
|
-
# an existing record
|
|
272
|
+
# @param insert_only [Boolean] when true, an id already in the head is left untouched
|
|
273
|
+
# @return [Symbol] `:inserted`, `:replaced`, or `:conflicted` when `insert_only` hit a record
|
|
523
274
|
# @raise [Runtime::WiringError] if this boot's era has been superseded by a mint
|
|
524
275
|
# @raise [PG::Error] if Postgres refuses a statement
|
|
525
276
|
def atomic_put(entry, insert_only: false)
|
|
@@ -541,31 +292,22 @@ module Hecks
|
|
|
541
292
|
status
|
|
542
293
|
end
|
|
543
294
|
|
|
544
|
-
# Builds the instance an entry describes, writing nothing
|
|
545
|
-
#
|
|
546
|
-
# The head is derived — projecting is reading, so there is nothing
|
|
547
|
-
# to write here. `append` above already keeps the snapshot the head
|
|
548
|
-
# view reads from current, transactionally. The instance is still
|
|
549
|
-
# built (and validated) so a save returns what every other adapter
|
|
550
|
-
# returns.
|
|
295
|
+
# Builds the instance an entry describes, writing nothing; the head is derived, so
|
|
296
|
+
# projecting is reading.
|
|
551
297
|
#
|
|
552
298
|
# @param entry [Ports::Persistence::Entry] a journaled save or delete
|
|
553
299
|
# @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`
|
|
300
|
+
# @raise [Runtime::WiringError] if the entry's state is still in its stored form
|
|
556
301
|
def project(entry)
|
|
557
302
|
return if entry.delete?
|
|
558
303
|
|
|
559
304
|
Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: entry.state)
|
|
560
305
|
end
|
|
561
306
|
|
|
562
|
-
# Reads this aggregate's whole journal, every era, in
|
|
563
|
-
#
|
|
564
|
-
# it was written in.
|
|
307
|
+
# Reads this aggregate's whole journal, every era, in write order. States are decoded but
|
|
308
|
+
# not translated: an ancestor era's row keeps the shape it was written in.
|
|
565
309
|
#
|
|
566
|
-
# @return [Array<Ports::Persistence::Entry>]
|
|
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
|
|
310
|
+
# @return [Array<Ports::Persistence::Entry>] entries by ascending ordinal
|
|
569
311
|
def entries
|
|
570
312
|
@db.exec_params(
|
|
571
313
|
"SELECT aggregate_id, operation, state, mirrors FROM #{@lineage.quoted_journal} " \
|
|
@@ -582,30 +324,15 @@ module Hecks
|
|
|
582
324
|
end
|
|
583
325
|
end
|
|
584
326
|
|
|
585
|
-
# Deletes this aggregate's journal rows, refusing when row-level security
|
|
586
|
-
#
|
|
587
|
-
#
|
|
588
|
-
# The journal carries FORCE ROW LEVEL SECURITY with exactly two
|
|
589
|
-
# policies — hecks_current_era's INSERT and hecks_read_all's
|
|
590
|
-
# SELECT (advance_era! above) — and no DELETE policy at all, for
|
|
591
|
-
# anyone. `FORCE` means even the table's own owner is fenced by
|
|
592
|
-
# that (only an actual Postgres superuser or a role granted
|
|
593
|
-
# BYPASSRLS sits above it — see lineage.rb's own header), so a
|
|
594
|
-
# plain `DELETE ... WHERE aggregate = $1` from an ordinary
|
|
595
|
-
# connection silently matches zero rows: no privilege error, no
|
|
596
|
-
# exception, just a no-op that looks like success. Counting
|
|
597
|
-
# before and comparing to what the DELETE itself reports is what
|
|
598
|
-
# tells "nothing to delete" apart from "RLS silently ate the
|
|
599
|
-
# delete" — the same row count, from the same statement, either
|
|
600
|
-
# way, with no separate query racing the DELETE for an answer.
|
|
327
|
+
# Deletes this aggregate's journal rows, refusing when row-level security turns the DELETE
|
|
328
|
+
# into a silent no-op.
|
|
601
329
|
#
|
|
602
|
-
#
|
|
603
|
-
#
|
|
330
|
+
# The journal forces RLS with no DELETE policy, so an ordinary connection matches zero rows
|
|
331
|
+
# without error; comparing the count before with the DELETE's own count exposes that.
|
|
332
|
+
# Head snapshots, field caches, `events` and saga rows are left as they are.
|
|
604
333
|
#
|
|
605
334
|
# @return [Adapters::PostgresEra] this adapter
|
|
606
|
-
# @raise [Runtime::WiringError] if the journal held rows
|
|
607
|
-
# DELETE removed none, which means the connection is neither a superuser nor
|
|
608
|
-
# granted BYPASSRLS
|
|
335
|
+
# @raise [Runtime::WiringError] if the journal held rows and the DELETE removed none
|
|
609
336
|
def reset!
|
|
610
337
|
before = @db.exec_params(
|
|
611
338
|
"SELECT count(*) FROM #{@lineage.quoted_journal} WHERE aggregate = $1", [table]
|
|
@@ -621,86 +348,39 @@ module Hecks
|
|
|
621
348
|
self
|
|
622
349
|
end
|
|
623
350
|
|
|
624
|
-
# Journals an instance's current state as a save entry.
|
|
625
|
-
#
|
|
626
|
-
# `Ports::Persistence::AppendOnly`, which builds the entry itself.
|
|
351
|
+
# Journals an instance's current state as a save entry. A direct-adapter convenience for
|
|
352
|
+
# specs and consoles; a runtime saves through `Ports::Persistence::AppendOnly`.
|
|
627
353
|
#
|
|
628
354
|
# @param instance [Runtime::Instance] the record to persist
|
|
629
355
|
# @return [Runtime::Instance] a fresh instance built from the journaled state
|
|
630
356
|
# @raise [Runtime::WiringError] if this boot's era has been superseded by a mint
|
|
631
|
-
# @raise [PG::Error] if Postgres refuses a statement
|
|
632
357
|
def save(instance)
|
|
633
358
|
entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
|
|
634
359
|
append(entry)
|
|
635
360
|
project(entry)
|
|
636
361
|
end
|
|
637
362
|
|
|
638
|
-
# Journals a delete entry
|
|
639
|
-
#
|
|
363
|
+
# Journals a delete entry and tombstones the id in the head snapshot, without checking
|
|
364
|
+
# that the record exists.
|
|
640
365
|
#
|
|
641
366
|
# @param id [String, Object] the record's identity, journaled as `id.to_s`
|
|
642
|
-
# @return [true] always
|
|
367
|
+
# @return [true] always
|
|
643
368
|
# @raise [Runtime::WiringError] if this boot's era has been superseded by a mint
|
|
644
|
-
# @raise [PG::Error] if Postgres refuses a statement
|
|
645
369
|
def delete(id)
|
|
646
370
|
entry = Ports::Persistence::Entry.new(operation: "delete", id: id.to_s, state: nil)
|
|
647
371
|
append(entry)
|
|
648
372
|
true
|
|
649
373
|
end
|
|
650
374
|
|
|
651
|
-
#
|
|
652
|
-
#
|
|
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
|
|
658
|
-
def record_event(event)
|
|
659
|
-
@db.exec_params(
|
|
660
|
-
"INSERT INTO events (name, aggregate, aggregate_id, payload, occurred_at) VALUES ($1, $2, $3, $4, $5)",
|
|
661
|
-
[event.name, event.aggregate, event.id.to_s, JSON.generate(event.payload), event.occurred_at]
|
|
662
|
-
)
|
|
663
|
-
end
|
|
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
|
|
670
|
-
def events
|
|
671
|
-
@db.exec("SELECT * FROM events ORDER BY id").map do |row|
|
|
672
|
-
Runtime::Event.new(
|
|
673
|
-
name: row["name"],
|
|
674
|
-
aggregate: row["aggregate"],
|
|
675
|
-
id: row["aggregate_id"],
|
|
676
|
-
payload: JSON.parse(row["payload"], symbolize_names: true),
|
|
677
|
-
occurred_at: row["occurred_at"]
|
|
678
|
-
)
|
|
679
|
-
end
|
|
680
|
-
end
|
|
375
|
+
# Saga rows keep `domain` as a column so domains sharing one schema stay isolated. No lock
|
|
376
|
+
# here: `SagaInterpreter`'s own mutex already serializes in-process writers.
|
|
681
377
|
|
|
682
|
-
#
|
|
683
|
-
#
|
|
684
|
-
# correlation), `domain` kept as an explicit column even under
|
|
685
|
-
# schema isolation so two domains sharing one schema (neither
|
|
686
|
-
# declares its own `schema`) still isolate correctly, matching
|
|
687
|
-
# `hecks_eras`' own precedent (postgres/lineage/provisioning.rb).
|
|
688
|
-
# No advisory lock of its own: every call here already runs inside
|
|
689
|
-
# `SagaInterpreter`'s own mutex (§7) serializing in-process writers,
|
|
690
|
-
# and gets the same cross-process safety an aggregate's own writes
|
|
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.
|
|
378
|
+
# Checkpoints one saga instance, replacing the row for the same (domain, process manager,
|
|
379
|
+
# correlation) if one exists.
|
|
695
380
|
#
|
|
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
381
|
# @param memory [Hash] the saga's memory, stored as JSON
|
|
701
|
-
# @param completed_compensations [Array]
|
|
702
|
-
#
|
|
703
|
-
# @return [PG::Result] the upsert's result, which carries no rows
|
|
382
|
+
# @param completed_compensations [Array] compensable legs already completed, stored as JSON
|
|
383
|
+
# @return [PG::Result] the upsert's result
|
|
704
384
|
def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
|
|
705
385
|
@db.exec_params(
|
|
706
386
|
"INSERT INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, completed_compensations) " \
|
|
@@ -715,10 +395,7 @@ module Hecks
|
|
|
715
395
|
|
|
716
396
|
# Removes a finished saga instance's checkpoint; a no-op when no such row exists.
|
|
717
397
|
#
|
|
718
|
-
# @
|
|
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
|
|
398
|
+
# @return [PG::Result] the DELETE's result
|
|
722
399
|
def delete_saga(process_manager:, correlation:)
|
|
723
400
|
@db.exec_params(
|
|
724
401
|
"DELETE FROM hecks_saga_instances WHERE domain = $1 AND process_manager = $2 AND correlation = $3",
|
|
@@ -726,18 +403,10 @@ module Hecks
|
|
|
726
403
|
)
|
|
727
404
|
end
|
|
728
405
|
|
|
729
|
-
# Yields every saga checkpoint stored for this domain, so a booting registry can
|
|
730
|
-
# rehydrate its in-flight sagas.
|
|
406
|
+
# Yields every saga checkpoint stored for this domain, so a booting registry can rehydrate.
|
|
731
407
|
#
|
|
732
|
-
# @yieldparam
|
|
733
|
-
# @
|
|
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
|
|
408
|
+
# @yieldparam memory [Hash{Symbol => Object}] the saga's memory, keys symbolized
|
|
409
|
+
# @return [Enumerator, PG::Result] an enumerator when no block is given
|
|
741
410
|
def each_saga
|
|
742
411
|
return enum_for(:each_saga) unless block_given?
|
|
743
412
|
|
|
@@ -777,34 +446,14 @@ module Hecks
|
|
|
777
446
|
"state = EXCLUDED.state WHERE #{quoted_head_snapshot}.ordinal < EXCLUDED.ordinal",
|
|
778
447
|
[entry.id, ordinal, state_json]
|
|
779
448
|
)
|
|
780
|
-
#
|
|
781
|
-
# exactly as current as the snapshot it's derived from, for
|
|
782
|
-
# the identical reason `postgres_era.rb`'s own header comment
|
|
783
|
-
# gives for the journal/snapshot pair: if this transaction
|
|
784
|
-
# commits, every cache row is already correct; if it doesn't,
|
|
785
|
-
# none of them changed.
|
|
449
|
+
# Same transaction and ordinal, so every cache is exactly as current as the snapshot.
|
|
786
450
|
@field_caches.each do |field, cache_table|
|
|
787
451
|
@lineage.upsert_field_cache_row!(cache_table, entry.id, ordinal, state_json, query_expression(field))
|
|
788
452
|
end
|
|
789
453
|
else
|
|
790
|
-
# A tombstone row, not a bare DELETE
|
|
791
|
-
#
|
|
792
|
-
#
|
|
793
|
-
# once an ancestor era is in the picture: for a record carried
|
|
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
|
|
796
|
-
# outrank the ancestor matview's own (still-present, still
|
|
797
|
-
# `save`) row, so `DISTINCT ON` picks the ancestor's row and
|
|
798
|
-
# the "deleted" record keeps reading back forever. Upserting a
|
|
799
|
-
# tombstone (`operation = 'delete'`, `state` NULL) instead
|
|
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
|
|
802
|
-
# masked correctly" — the audit's own phrasing for why that
|
|
803
|
-
# half never breaks) — it just carries `operation
|
|
804
|
-
# = 'delete'` instead of `'save'`, so `head_view`'s own `WHERE
|
|
805
|
-
# operation = 'save'` still correctly hides it. Ordinal-guarded
|
|
806
|
-
# the same as every other upsert here, so an out-of-order
|
|
807
|
-
# replay can never let a stale delete clobber a newer save.
|
|
454
|
+
# A tombstone row, not a bare DELETE: without one, an ancestor era's saved row would win
|
|
455
|
+
# the head's DISTINCT ON and the deleted record would read back. The ordinal guard keeps
|
|
456
|
+
# a stale replayed delete from clobbering a newer save.
|
|
808
457
|
@db.exec_params(
|
|
809
458
|
"INSERT INTO #{quoted_head_snapshot} (id, ordinal, operation, state) VALUES ($1, $2, 'delete', NULL) " \
|
|
810
459
|
"ON CONFLICT (id) DO UPDATE SET ordinal = EXCLUDED.ordinal, operation = EXCLUDED.operation, " \
|
|
@@ -817,8 +466,6 @@ module Hecks
|
|
|
817
466
|
ordinal
|
|
818
467
|
end
|
|
819
468
|
|
|
820
|
-
# ── SqlQueryBuilder's dialect hooks ─────────────────────────────
|
|
821
|
-
|
|
822
469
|
def select_list = "id, state"
|
|
823
470
|
def from_relation = quoted_head
|
|
824
471
|
def dialect_name = "PostgresEra"
|
|
@@ -854,17 +501,11 @@ module Hecks
|
|
|
854
501
|
@db.exec_params(sql, binds).map { |row| instance(row) }
|
|
855
502
|
end
|
|
856
503
|
|
|
857
|
-
# ── the rest of the dialect ─────────────────────────────────────
|
|
858
|
-
|
|
859
504
|
def instance(row)
|
|
860
505
|
Runtime::Instance.new(aggregate: @aggregate, id: row["id"], state: decode(row["state"]))
|
|
861
506
|
end
|
|
862
507
|
|
|
863
|
-
#
|
|
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.
|
|
508
|
+
# Runs after SQL-side era translation, as the last step of every read.
|
|
868
509
|
def decode(state_json)
|
|
869
510
|
Ports::Persistence::StateCodec.decode(@aggregate, JSON.parse(state_json))
|
|
870
511
|
end
|
|
@@ -878,11 +519,8 @@ module Hecks
|
|
|
878
519
|
numeric_field?(field) ? "(#{expression})::numeric" : expression
|
|
879
520
|
end
|
|
880
521
|
|
|
881
|
-
# Postgres defaults to NULLS LAST on ASC;
|
|
882
|
-
#
|
|
883
|
-
# to match) put null rows FIRST ascending and LAST
|
|
884
|
-
# descending. Compile the placement explicitly so a declared query
|
|
885
|
-
# answers identically no matter which adapter serves it.
|
|
522
|
+
# Postgres defaults to NULLS LAST on ASC; place nulls explicitly (first ascending, last
|
|
523
|
+
# descending) so a declared query answers the same on every adapter.
|
|
886
524
|
def order_clause(order_by, policy)
|
|
887
525
|
direction = order_by.direction.to_s.downcase == "desc" ? "DESC" : "ASC"
|
|
888
526
|
nulls = case policy&.mode.to_s
|
|
@@ -893,10 +531,7 @@ module Hecks
|
|
|
893
531
|
"#{order_expression(order_by.field)} #{direction}#{nulls}, id #{direction}"
|
|
894
532
|
end
|
|
895
533
|
|
|
896
|
-
# One shared walk decides numericness at any depth
|
|
897
|
-
# only the first nested segment would let a two-level path
|
|
898
|
-
# (pizza.price_cents.cents) skip the ::numeric cast and order
|
|
899
|
-
# as text: "900" above "1200".
|
|
534
|
+
# One shared walk decides numericness at any depth, so a nested path is not ordered as text.
|
|
900
535
|
def numeric_field?(field)
|
|
901
536
|
name, *path = field.to_s.split(".")
|
|
902
537
|
QuerySpecification::FieldPath.numeric?(@aggregate.attribute(name), path) do |type|
|
|
@@ -904,31 +539,16 @@ module Hecks
|
|
|
904
539
|
end
|
|
905
540
|
end
|
|
906
541
|
|
|
907
|
-
# `ARRAY[...]` of
|
|
908
|
-
#
|
|
909
|
-
# value-object member name, and while today's callers only ever
|
|
910
|
-
# pass schema-declared names, this method has no way to know
|
|
911
|
-
# that, and the '{...}' form has no escaping at all: a segment
|
|
912
|
-
# containing a single quote closes the string early and whatever
|
|
913
|
-
# follows becomes live SQL. Measured, not assumed — a crafted
|
|
914
|
-
# field name of `x}' = '' OR $1::text = $1::text -- ` made a
|
|
915
|
-
# `where(secret: "public")` clause return every row regardless,
|
|
916
|
-
# against the '{...}' form; the `ARRAY[]` form below closes it, verified
|
|
917
|
-
# against the identical payload.
|
|
542
|
+
# `ARRAY[...]` of escaped literals, never the '{a,b}' syntax: that form has no escaping, so
|
|
543
|
+
# a quote in a segment would become live SQL.
|
|
918
544
|
def jsonb_path(segments)
|
|
919
545
|
"state #>> ARRAY[#{segments.map { |segment| text_literal(segment) }.join(', ')}]::text[]"
|
|
920
546
|
end
|
|
921
547
|
|
|
922
548
|
def text_literal(text) = "'#{text.to_s.gsub("'", "''")}'"
|
|
923
549
|
|
|
924
|
-
#
|
|
925
|
-
|
|
926
|
-
# Every declared `where`-field this aggregate's own queries and its
|
|
927
|
-
# entities' own queries use, minus anything a cache table can't
|
|
928
|
-
# represent (see `cacheable_field?`) — never `order_by`-only
|
|
929
|
-
# fields, which never needed a cache in the first place (sorting
|
|
930
|
-
# the reduced output was never blocked by the reduction; only
|
|
931
|
-
# filtering was — see field_cache.rb's own header).
|
|
550
|
+
# Where-fields of the aggregate's and its entities' queries that a cache table can represent;
|
|
551
|
+
# order_by-only fields never needed a cache.
|
|
932
552
|
def ensure_field_caches!
|
|
933
553
|
cached_where_fields.to_h do |field|
|
|
934
554
|
[field, @lineage.ensure_field_cache!(table, @era, field, query_expression(field))]
|
|
@@ -948,14 +568,7 @@ module Hecks
|
|
|
948
568
|
@aggregate.queries + @aggregate.entities.flat_map(&:queries)
|
|
949
569
|
end
|
|
950
570
|
|
|
951
|
-
# List
|
|
952
|
-
# and `Sqlite`/`D1` adapters independently landed on for their own
|
|
953
|
-
# automatic indexing: `contains` means element membership, and a
|
|
954
|
-
# (id, ordinal, one value) cache row has nowhere to put more than
|
|
955
|
-
# one element. Everything else — a plain scalar, the lifecycle
|
|
956
|
-
# field, or a non-list value-object member path — reduces to
|
|
957
|
-
# exactly one comparable value per id, which is the one shape this
|
|
958
|
-
# cache table represents.
|
|
571
|
+
# List fields are excluded: a one-value cache row cannot hold `contains` membership.
|
|
959
572
|
def cacheable_field?(field)
|
|
960
573
|
name = field.to_s.split(".").first
|
|
961
574
|
return true if @aggregate.lifecycle&.field.to_s == name
|
|
@@ -964,28 +577,16 @@ module Hecks
|
|
|
964
577
|
!attribute.nil? && !attribute.list?
|
|
965
578
|
end
|
|
966
579
|
|
|
967
|
-
# A clause
|
|
968
|
-
#
|
|
969
|
-
# `QuerySpecification::Common::NullPolicy` intercepts before
|
|
970
|
-
# `where_clause` ever runs (see `query`'s own header comment) — a
|
|
971
|
-
# clause that fails either check simply flows to `head_phase`
|
|
972
|
-
# unaccelerated, exactly as `super` would have evaluated it.
|
|
580
|
+
# A clause is cache-served when its field has a cache table and it is not a null comparison,
|
|
581
|
+
# which `NullPolicy` intercepts before `where_clause`.
|
|
973
582
|
def cache_eligible?(clause, value)
|
|
974
583
|
@field_caches.key?(clause.field.to_s) &&
|
|
975
584
|
QuerySpecification::Common::NullPolicy.sql_predicate(query_expression(clause.field, value: value), clause.op,
|
|
976
585
|
value).nil?
|
|
977
586
|
end
|
|
978
587
|
|
|
979
|
-
#
|
|
980
|
-
#
|
|
981
|
-
# `INTERSECT`ed into the set that satisfies every cached clause at
|
|
982
|
-
# once. Reuses `where_clause` (SqlQueryBuilder, private, already
|
|
983
|
-
# mixed into this class) unchanged against the cache table's own
|
|
984
|
-
# `value` column instead of a jsonb path expression — the exact
|
|
985
|
-
# same operator compilation (`eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`in`)
|
|
986
|
-
# a live query already gets against the real column, so a cached
|
|
987
|
-
# field supports every comparator `super` would have, not just the
|
|
988
|
-
# `eq` example in the plan doc's own illustration.
|
|
588
|
+
# Phase one: candidate ids from the narrow cache tables, INTERSECTed. Reuses `where_clause`
|
|
589
|
+
# against each cache's `value` column, so every operator `super` supports works here.
|
|
989
590
|
def cache_phase(cached)
|
|
990
591
|
binds = []
|
|
991
592
|
clauses = cached.map do |clause, value|
|
|
@@ -996,20 +597,8 @@ module Hecks
|
|
|
996
597
|
@db.exec_params(clauses.join("\nINTERSECT\n"), binds).map { |row| row["id"] }
|
|
997
598
|
end
|
|
998
599
|
|
|
999
|
-
#
|
|
1000
|
-
#
|
|
1001
|
-
# exactly the way `SqlQueryBuilder#query` (`super`) already applies
|
|
1002
|
-
# every clause today: against the fully-reduced view, which is
|
|
1003
|
-
# already correct regardless of caching (see this file's own
|
|
1004
|
-
# `query` comment — a cache is a speed shortcut, never a
|
|
1005
|
-
# correctness fix; head_view was always safe to filter directly,
|
|
1006
|
-
# just expensive to reduce in the first place). Duplicates a small
|
|
1007
|
-
# slice of `SqlQueryBuilder#query`'s own tail assembly (order_by/
|
|
1008
|
-
# limit/offset) rather than reaching into it — principle 2
|
|
1009
|
-
# (docs/implemented/postgres-era-adapter-split-plan.md): SqlQueryBuilder stays
|
|
1010
|
-
# untouched by the era workaround, so this stays local to
|
|
1011
|
-
# PostgresEra rather than growing a shared hook only one adapter
|
|
1012
|
-
# would ever call.
|
|
600
|
+
# Phase two: the head view restricted to phase one's ids plus the clauses it could not
|
|
601
|
+
# accelerate. Repeats the tail of `SqlQueryBuilder#query` so that module stays untouched.
|
|
1013
602
|
def head_phase(declared, uncached, ids, args)
|
|
1014
603
|
binds = []
|
|
1015
604
|
clauses = ["id IN (#{ids.map { |id| placeholder(binds, id) }.join(', ')})"]
|
|
@@ -1034,19 +623,6 @@ module Hecks
|
|
|
1034
623
|
execute_query(sql, binds)
|
|
1035
624
|
end
|
|
1036
625
|
|
|
1037
|
-
def create_event_table!
|
|
1038
|
-
@db.exec(<<~SQL)
|
|
1039
|
-
CREATE TABLE IF NOT EXISTS events (
|
|
1040
|
-
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
1041
|
-
name text NOT NULL,
|
|
1042
|
-
aggregate text NOT NULL,
|
|
1043
|
-
aggregate_id text NOT NULL,
|
|
1044
|
-
payload jsonb,
|
|
1045
|
-
occurred_at text
|
|
1046
|
-
)
|
|
1047
|
-
SQL
|
|
1048
|
-
end
|
|
1049
|
-
|
|
1050
626
|
def create_saga_table!
|
|
1051
627
|
@db.exec(<<~SQL)
|
|
1052
628
|
CREATE TABLE IF NOT EXISTS hecks_saga_instances (
|
|
@@ -1060,10 +636,7 @@ module Hecks
|
|
|
1060
636
|
PRIMARY KEY (domain, process_manager, correlation)
|
|
1061
637
|
)
|
|
1062
638
|
SQL
|
|
1063
|
-
#
|
|
1064
|
-
# this same domain already created before this column existed —
|
|
1065
|
-
# the same idiom `rust/host/src/journal.rs`'s own
|
|
1066
|
-
# `sagas_backfilled` column addition already uses.
|
|
639
|
+
# Covers a table created before this column existed, where IF NOT EXISTS is a no-op.
|
|
1067
640
|
@db.exec("ALTER TABLE hecks_saga_instances ADD COLUMN IF NOT EXISTS completed_compensations jsonb " \
|
|
1068
641
|
"NOT NULL DEFAULT '[]'::jsonb")
|
|
1069
642
|
end
|