hecks 3.13.0 → 4.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 +4 -4
- data/examples/pizzas/adapters/fake_processor.rb +34 -0
- data/examples/pizzas/adapters/payment_signature.rb +66 -0
- data/examples/pizzas/adapters/payment_webhook.rb +87 -0
- data/examples/pizzas/bluebook/pizzas.behaviors +33 -0
- data/examples/pizzas/bluebook/pizzas.bluebook +4 -4
- data/examples/pizzas/bluebook/pizzas.hecksagon +5 -3
- data/examples/pizzas/config.ru +22 -0
- data/lib/hecks/adapters/driven/prism.rb +0 -2
- data/lib/hecks/adapters/driven.rb +0 -2
- data/lib/hecks/adapters/driving/json.rb +1 -7
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +15 -5
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -2
- data/lib/hecks/bluebook/dsl/derivation_words.rb +58 -0
- data/lib/hecks/bluebook/dsl/translation_builder.rb +6 -37
- data/lib/hecks/bluebook/expression/ast_json.rb +0 -9
- data/lib/hecks/bluebook/hecksagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/translation_judge/rules.rb +7 -10
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +4 -8
- data/lib/hecks/bluebook/meta_validator.rb +0 -11
- data/lib/hecks/bluebook/named_pattern.rb +33 -0
- data/lib/hecks/bluebook/translation.rb +18 -10
- data/lib/hecks/bluebook/translation_expression/evaluator.rb +63 -0
- data/lib/hecks/bluebook/translation_expression/parser.rb +127 -0
- data/lib/hecks/bluebook/translation_expression.rb +39 -0
- data/lib/hecks/bluebook/translation_plan.rb +70 -0
- data/lib/hecks/bluebook.rb +3 -0
- data/lib/hecks/chapters.rb +3 -3
- data/lib/hecks/cli/domain_stub.rb +1 -1
- data/lib/hecks/cli/interview_aggregate.rb +4 -4
- data/lib/hecks/composer/adapters/claude_composer.adapter +3 -0
- data/lib/hecks/composer/adapters/claude_composer.rb +83 -0
- data/lib/hecks/composer/adapters/fake_agent.adapter +3 -0
- data/lib/hecks/composer/adapters/fake_agent.rb +55 -0
- data/lib/hecks/composer/adapters/jev.adapter +3 -0
- data/lib/hecks/composer/adapters/jev.rb +66 -0
- data/lib/hecks/composer/agent.rb +113 -0
- data/lib/hecks/composer/assistant.rb +110 -0
- data/lib/hecks/composer/behavior_run.rb +53 -0
- data/lib/hecks/composer/bluebook/composer.bluebook +178 -0
- data/lib/hecks/composer/bluebook/composer.hecksagon +16 -0
- data/lib/hecks/composer/board.rb +65 -0
- data/lib/hecks/composer/board_edit/command_text.rb +39 -0
- data/lib/hecks/composer/board_edit/source.rb +84 -0
- data/lib/hecks/composer/board_edit.rb +144 -0
- data/lib/hecks/composer/check.rb +89 -0
- data/lib/hecks/composer/domains.rb +81 -0
- data/lib/hecks/composer/jobs.rb +134 -0
- data/lib/hecks/composer/layout.rb +37 -0
- data/lib/hecks/composer/live.rb +150 -0
- data/lib/hecks/composer/outline.rb +48 -0
- data/lib/hecks/composer/page.html +989 -0
- data/lib/hecks/composer/panels.rb +142 -0
- data/lib/hecks/composer/parse.rb +83 -0
- data/lib/hecks/composer/plain.rb +28 -0
- data/lib/hecks/composer/ports/composer_agent.port +7 -0
- data/lib/hecks/composer/session.rb +141 -0
- data/lib/hecks/composer/toolbox.rb +166 -0
- data/lib/hecks/composer/web.rb +156 -0
- data/lib/hecks/composer/workbench.rb +51 -0
- data/lib/hecks/composer/workspace.rb +81 -0
- data/lib/hecks/composer.rb +17 -0
- data/lib/hecks/corpus/adapters.rb +22 -0
- data/lib/hecks/corpus/rust.rb +1 -1
- data/lib/hecks/corpus/worlds.rb +19 -0
- data/lib/hecks/corpus.rb +5 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -34
- data/lib/hecks/forms/app/commands.rb +2 -3
- data/lib/hecks/forms/app/queries.rb +2 -3
- data/lib/hecks/forms/app/records.rb +17 -5
- data/lib/hecks/forms/command_form_renderer.rb +42 -16
- data/lib/hecks/forms/field_controls.rb +2 -2
- data/lib/hecks/forms/field_renderer.rb +1 -1
- data/lib/hecks/forms/field_shape.rb +36 -17
- data/lib/hecks/forms/index_renderer.rb +2 -1
- data/lib/hecks/forms/page.rb +1 -1
- data/lib/hecks/forms/params.rb +0 -13
- data/lib/hecks/forms/query_form_renderer.rb +7 -6
- data/lib/hecks/forms/record_renderer.rb +11 -7
- data/lib/hecks/forms/rule_words.rb +100 -0
- data/lib/hecks/forms/value_object_shape.rb +2 -1
- data/lib/hecks/framework/bluebook/change.bluebook +2 -2
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -3
- data/lib/hecks/fuzzing/domain_generator/block_rendering.rb +1 -1
- data/lib/hecks/fuzzing/isolated_boot/worlds.rb +3 -6
- data/lib/hecks/fuzzing/replay/filters.rb +2 -6
- data/lib/hecks/gate/stages.yml +25 -0
- data/lib/hecks/grammar/evolve/seeds.rb +0 -6
- data/lib/hecks/grammar/evolve.rb +0 -3
- data/lib/hecks/grammar/translation.bluebook +1 -1
- data/lib/hecks/hecks/adapters/codebase/composing.rb +56 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
- data/lib/hecks/hecks/adapters/journal_store/changes.rb +0 -26
- data/lib/hecks/hecks/adapters/journal_store/examination.rb +2 -23
- data/lib/hecks/hecks/adapters/journal_store/readings.rb +3 -11
- data/lib/hecks/hecks/adapters/journal_store.rb +0 -11
- data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +4 -11
- data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +0 -9
- data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +0 -4
- data/lib/hecks/hecks/codebase.behaviors +6 -0
- data/lib/hecks/hecks/codebase.bluebook +58 -37
- data/lib/hecks/hecks/composer.behaviors +68 -0
- data/lib/hecks/hecks/context_map.hecksagon +1 -1
- data/lib/hecks/hecks/custodian.behaviors +0 -6
- data/lib/hecks/hecks/custodian.bluebook +15 -92
- data/lib/hecks/hecks/hecks.bluebook +2 -2
- data/lib/hecks/hecks/hecks.hecksagon +2 -2
- data/lib/hecks/hecks/hecks.world +1 -1
- data/lib/hecks/hecks/rehearsal.bluebook +10 -16
- data/lib/hecks/hecks/tooling.bluebook +22 -22
- data/lib/hecks/kernel_capabilities/templates.rb +4 -4
- data/lib/hecks/kernel_capabilities.rb +1 -1
- data/lib/hecks/language/bluebook/aggregate.bluebook +1 -0
- data/lib/hecks/language/bluebook/command.bluebook +1 -0
- data/lib/hecks/language/bluebook/entity.bluebook +1 -0
- data/lib/hecks/language/bluebook/query.bluebook +1 -0
- data/lib/hecks/language/bluebook/shape.bluebook +8 -7
- data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -3
- data/lib/hecks/language/hecksagon/port_operation.bluebook +1 -0
- data/lib/hecks/language/translation/translation_aggregate.bluebook +21 -24
- data/lib/hecks/ports/access_control.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -41
- data/lib/hecks/ports/persistence/plugins/era/lineage/coverage.rb +7 -7
- data/lib/hecks/ports/persistence/plugins/era/lineage/rewrite.rb +25 -2
- data/lib/hecks/ports/persistence/plugins/era/lineage/rules.rb +4 -4
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +17 -25
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/base_sql.rb +0 -12
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/domain_rename.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -22
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +9 -3
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +3 -29
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/refusals.rb +2 -26
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +6 -33
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +2 -4
- data/lib/hecks/ports/persistence/plugins/era/translation/expression_sql.rb +52 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +23 -29
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +3 -6
- data/lib/hecks/ports.rb +0 -1
- data/lib/hecks/projection_files.rb +3 -1
- data/lib/hecks/projections/deploy/templates/lambda/nat_comment_oauth.tmpl +1 -1
- data/lib/hecks/projections/glossary/entries.rb +10 -4
- data/lib/hecks/projections/glossary/groups.rb +42 -0
- data/lib/hecks/projections/glossary/html/page.rb +7 -2
- data/lib/hecks/projections/glossary/html/parser.rb +1 -1
- data/lib/hecks/projections/glossary/html/sections.rb +31 -11
- data/lib/hecks/projections/glossary/html.rb +1 -2
- data/lib/hecks/projections/glossary/markdown.rb +31 -28
- data/lib/hecks/projections/glossary/mermaid.rb +3 -0
- data/lib/hecks/projections/glossary/page.css +143 -153
- data/lib/hecks/projections/glossary/page.js +63 -3
- data/lib/hecks/projections/glossary/sentences/actions.rb +88 -0
- data/lib/hecks/projections/glossary/sentences/paths.rb +77 -0
- data/lib/hecks/projections/glossary/sentences/queries.rb +90 -0
- data/lib/hecks/projections/glossary/sentences/value_objects.rb +32 -5
- data/lib/hecks/projections/glossary/sentences.rb +13 -3
- data/lib/hecks/projections/glossary/slugs.rb +4 -1
- data/lib/hecks/projections/glossary.rb +2 -1
- data/lib/hecks/projections/plain_english.rb +97 -0
- data/lib/hecks/projections/rust_vocabulary/arguments.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary/enum_body.rb +20 -25
- data/lib/hecks/projections/rust_vocabulary/tests.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary.rb +14 -6
- data/lib/hecks/projections/site/cms_editor/color.rb +0 -6
- data/lib/hecks/projections/statements/actions.rb +99 -0
- data/lib/hecks/projections/statements/relationships.rb +86 -0
- data/lib/hecks/projections/statements.rb +15 -24
- data/lib/hecks/projector/exporter/adapters.rb +112 -0
- data/lib/hecks/projector/exporter/translations.rb +18 -35
- data/lib/hecks/projector/exporter/worlds.rb +103 -0
- data/lib/hecks/projector/exporter.rb +5 -1
- data/lib/hecks/quality_control/adapters/agent.rb +6 -7
- data/lib/hecks/quality_control/adapters/agent_profile.rb +16 -114
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +1 -1
- data/lib/hecks/quality_control/quality_control.bluebook +51 -51
- data/lib/hecks/runtime/loader/boot_steps.rb +0 -20
- data/lib/hecks/runtime/outbox.rb +2 -9
- data/lib/hecks/runtime/port_operation_interpreter.rb +1 -9
- data/lib/hecks/runtime/remote_dispatcher.rb +2 -2
- data/lib/hecks/runtime/value.rb +13 -0
- data/lib/hecks/rust_build/project_rust/target_ir.rb +23 -4
- data/lib/hecks/rust_build/wasm.rb +0 -12
- data/lib/hecks/rust_build/write_if_changed.rb +12 -12
- data/lib/hecks/rust_format.rb +77 -0
- data/lib/hecks/site/bluebook/site.bluebook +5 -5
- data/lib/hecks/sme/bluebook/sme.bluebook +12 -12
- data/lib/hecks/tickets/bluebook/tickets.bluebook +3 -3
- data/lib/hecks/tools/fuzz_sweep/findings.rb +3 -9
- data/lib/hecks/tools/fuzz_sweep.rb +1 -3
- data/lib/hecks/tools/translation_audit/verdicts.rb +3 -28
- data/lib/hecks/tools/translation_audit.rb +8 -10
- data/lib/hecks/tools/translation_scaffold/reporting.rb +1 -1
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- data/lib/hecks.rb +3 -0
- data/prebuilt/{58208399bada10b0db99928c48e38789288fc58f7b71a72b21bfa386680e32ed.marshal → 02c09c73b272c1fb5a9ae6d87bb5e5e529208e00f6b4b597dc2c0fd70a9c0964.marshal} +0 -0
- data/prebuilt/{2b59737fbdbd5f44edda0fddce2611f45d76087b3a7c782d1f1a17ea58c77948.marshal → bb84a835cf6a15136bad5ca03aa5bf93a5b92f5f72444618d3ef2412b987fb32.marshal} +0 -0
- data/prebuilt/verdicts-f1f10da14c8e8f01b4c9b7e3e2d74be8cd2bf4676364ed17fa16975d2a076d75.json +1 -0
- data/rust/Cargo.toml +40 -0
- data/rust/build/Cargo.toml +36 -0
- data/rust/build/clippy.toml +4 -0
- data/rust/build/src/adapters_pass.rs +196 -0
- data/rust/build/src/binding_pass.rs +54 -13
- data/rust/build/src/build_artifact.rs +23 -5
- data/rust/build/src/cargo_sync.rs +69 -26
- data/rust/build/src/lineage_pass.rs +101 -87
- data/rust/build/src/main.rs +11 -2
- data/rust/build/src/pipeline.rs +80 -19
- data/rust/build/src/reserved_names.rs +6 -50
- data/rust/build/src/resolve.rs +82 -35
- data/rust/build/src/rustfmt.rs +100 -0
- data/rust/build/src/seams_pass.rs +85 -16
- data/rust/build/src/subprocess.rs +30 -6
- data/rust/build/src/tmp.rs +5 -2
- data/rust/build/src/translation_pass.rs +72 -22
- data/rust/build/src/world_pass.rs +109 -0
- data/rust/clippy.toml +4 -0
- data/rust/codegen/Cargo.toml +36 -0
- data/rust/codegen/clippy.toml +4 -0
- data/rust/codegen/src/asks.rs +40 -14
- data/rust/codegen/src/attr.rs +2 -2
- data/rust/codegen/src/bridging.rs +676 -117
- data/rust/codegen/src/commands.rs +979 -373
- data/rust/codegen/src/constraints.rs +88 -25
- data/rust/codegen/src/dependency_planning.rs +128 -34
- data/rust/codegen/src/domain_generator.rs +300 -170
- data/rust/codegen/src/exemplar.rs +87 -32
- data/rust/codegen/src/expr_emitter.rs +54 -6
- data/rust/codegen/src/fielded.rs +193 -41
- data/rust/codegen/src/hecks_naming.rs +124 -16
- data/rust/codegen/src/hecks_naming_tests.rs +81 -0
- data/rust/codegen/src/json.rs +80 -44
- data/rust/codegen/src/json_codec.rs +585 -149
- data/rust/codegen/src/literal.rs +104 -138
- data/rust/codegen/src/main.rs +145 -52
- data/rust/codegen/src/manifest.rs +76 -14
- data/rust/codegen/src/mutations.rs +1212 -239
- data/rust/codegen/src/naming.rs +111 -60
- data/rust/codegen/src/optional_pass.rs +90 -28
- data/rust/codegen/src/ports.rs +5 -4
- data/rust/codegen/src/prelude.rs +139 -49
- data/rust/codegen/src/queries.rs +601 -226
- data/rust/codegen/src/reactions.rs +268 -74
- data/rust/codegen/src/read_models.rs +606 -205
- data/rust/codegen/src/reference_specs.rs +16 -3
- data/rust/codegen/src/registry.rs +128 -79
- data/rust/codegen/src/reserved_names.rs +6 -50
- data/rust/codegen/src/shared.rs +71 -22
- data/rust/codegen/src/sidecars.rs +15 -5
- data/rust/codegen/src/skip_reason.rs +8 -2
- data/rust/codegen/src/types.rs +306 -108
- data/rust/host/Cargo.lock +1 -0
- data/rust/host/Cargo.toml +40 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/clippy.toml +4 -0
- data/rust/host/src/api.rs +698 -218
- data/rust/host/src/auth.rs +491 -214
- data/rust/host/src/b64.rs +91 -0
- data/rust/host/src/bin/expr_harness.rs +6 -2
- data/rust/host/src/bin/lineage_harness.rs +47 -11
- data/rust/host/src/bin/mint_harness.rs +70 -37
- data/rust/host/src/bin/validate_harness.rs +16 -8
- data/rust/host/src/boot.rs +144 -68
- data/rust/host/src/boundary_fuzz/api.rs +130 -32
- data/rust/host/src/boundary_fuzz/auth.rs +108 -23
- data/rust/host/src/boundary_fuzz/expr_json.rs +85 -11
- data/rust/host/src/boundary_fuzz/ir.rs +164 -12
- data/rust/host/src/boundary_fuzz/server.rs +283 -36
- data/rust/host/src/boundary_fuzz/web.rs +56 -8
- data/rust/host/src/checkout.rs +78 -32
- data/rust/host/src/clock.rs +8 -0
- data/rust/host/src/commerce_ir.rs +333 -95
- data/rust/host/src/crypto.rs +33 -0
- data/rust/host/src/dispatch.rs +990 -331
- data/rust/host/src/expr_json.rs +1021 -273
- data/rust/host/src/extension.rs +24 -4
- data/rust/host/src/field_hints.rs +11 -7
- data/rust/host/src/fuzz_support.rs +85 -13
- data/rust/host/src/host.rs +100 -0
- data/rust/host/src/http.rs +22 -0
- data/rust/host/src/ir.rs +326 -16
- data/rust/host/src/journal.rs +277 -176
- data/rust/host/src/lambda_client.rs +219 -53
- data/rust/host/src/lib.rs +16 -4
- data/rust/host/src/log.rs +62 -11
- data/rust/host/src/mint.rs +1153 -326
- data/rust/host/src/needs.rs +185 -44
- data/rust/host/src/payments/keystore.rs +197 -45
- data/rust/host/src/payments/roles.rs +191 -0
- data/rust/host/src/payments/tests.rs +1445 -275
- data/rust/host/src/payments.rs +409 -121
- data/rust/host/src/platform_signin/attack_tests.rs +5 -5
- data/rust/host/src/platform_signin/config.rs +11 -11
- data/rust/host/src/platform_signin/cookie.rs +2 -2
- data/rust/host/src/platform_signin/extension.rs +2 -1
- data/rust/host/src/platform_signin/handler.rs +9 -8
- data/rust/host/src/platform_signin/jti.rs +4 -2
- data/rust/host/src/platform_signin/jwks.rs +2 -1
- data/rust/host/src/platform_signin/jws.rs +2 -37
- data/rust/host/src/platform_signin/known_answer_tests.rs +5 -1
- data/rust/host/src/platform_signin/mode_tests.rs +5 -3
- data/rust/host/src/platform_signin/pages.rs +20 -20
- data/rust/host/src/platform_signin/session.rs +8 -1
- data/rust/host/src/platform_signin/settings.rs +6 -6
- data/rust/host/src/platform_signin/strict_json.rs +12 -9
- data/rust/host/src/platform_signin/verify.rs +41 -41
- data/rust/host/src/platform_signin/wiring_support.rs +13 -18
- data/rust/host/src/presentation.rs +207 -63
- data/rust/host/src/presentation_write.rs +502 -187
- data/rust/host/src/query_step.rs +69 -18
- data/rust/host/src/rate_limit.rs +541 -142
- data/rust/host/src/reference_transform.rs +77 -25
- data/rust/host/src/reference_validate.rs +443 -104
- data/rust/host/src/resend.rs +190 -55
- data/rust/host/src/secrets.rs +116 -21
- data/rust/host/src/server.rs +483 -104
- data/rust/host/src/storage_shape.rs +88 -21
- data/rust/host/src/test_pg.rs +17 -7
- data/rust/host/src/ui_schema.rs +495 -207
- data/rust/host/src/wasm_runner.rs +21 -6
- data/rust/host/src/web/commerce.rs +101 -23
- data/rust/host/src/web/newsletter/cooldown.rs +10 -3
- data/rust/host/src/web/newsletter/template.rs +26 -7
- data/rust/host/src/web/newsletter/tests.rs +112 -23
- data/rust/host/src/web/newsletter.rs +209 -59
- data/rust/host/src/web/newsletter_send.rs +251 -79
- data/rust/host/src/web/registration_receipt/tests.rs +16 -4
- data/rust/host/src/web/registration_receipt.rs +100 -24
- data/rust/host/src/web/registrations.rs +1384 -332
- data/rust/host/src/web/resend_webhook.rs +138 -80
- data/rust/host/src/web.rs +2416 -508
- data/rust/lsp/Cargo.toml +36 -0
- data/rust/lsp/clippy.toml +4 -0
- data/rust/lsp/src/json.rs +29 -22
- data/rust/lsp/src/main.rs +81 -31
- data/rust/lsp/src/outline.rs +23 -7
- data/rust/lsp/src/rpc.rs +17 -4
- data/rust/parser/Cargo.toml +36 -0
- data/rust/parser/clippy.toml +4 -0
- data/rust/parser/src/build/closed_sets.rs +6 -1
- data/rust/parser/src/build/identity.rs +4 -3
- data/rust/parser/src/build/mod.rs +1 -0
- data/rust/parser/src/build/named_pattern.rs +26 -0
- data/rust/parser/src/build/naming.rs +2 -80
- data/rust/parser/src/build/pattern_subset.rs +6 -8
- data/rust/parser/src/build/query_derive.rs +3 -1
- data/rust/parser/src/build/query_inference.rs +14 -5
- data/rust/parser/src/build/read_model.rs +4 -1
- data/rust/parser/src/build/references.rs +4 -3
- data/rust/parser/src/canonical.rs +34 -15
- data/rust/parser/src/emit.rs +187 -105
- data/rust/parser/src/expr/ast_json.rs +134 -42
- data/rust/parser/src/expr/evaluator.rs +27 -9
- data/rust/parser/src/expr/mod.rs +54 -13
- data/rust/parser/src/expr/resolver.rs +299 -92
- data/rust/parser/src/ir.rs +35 -3
- data/rust/parser/src/keywords.rs +7168 -510
- data/rust/parser/src/lex.rs +11 -25
- data/rust/parser/src/lib.rs +1 -0
- data/rust/parser/src/main.rs +65 -6
- data/rust/parser/src/parse/adapter.rs +157 -0
- data/rust/parser/src/parse/aggregate.rs +19 -10
- data/rust/parser/src/parse/chapter.rs +16 -17
- data/rust/parser/src/parse/command.rs +35 -16
- data/rust/parser/src/parse/domain_port.rs +4 -7
- data/rust/parser/src/parse/entity.rs +21 -11
- data/rust/parser/src/parse/hecksagon.rs +10 -4
- data/rust/parser/src/parse/lifecycle.rs +9 -5
- data/rust/parser/src/parse/mod.rs +43 -91
- data/rust/parser/src/parse/needs.rs +21 -12
- data/rust/parser/src/parse/policy.rs +2 -2
- data/rust/parser/src/parse/process_manager.rs +9 -11
- data/rust/parser/src/parse/query.rs +8 -4
- data/rust/parser/src/parse/read_model.rs +57 -16
- data/rust/parser/src/parse/value_object.rs +7 -2
- data/rust/parser/src/parse/world.rs +322 -0
- data/rust/parser/src/ruby_value.rs +7 -6
- data/rust/src/exemplar/json.rs +4 -4
- data/rust/src/exemplar/mutations.rs +18 -0
- data/rust/src/exemplar/read_models.rs +1 -10
- data/rust/src/kernel/attribute_shapes/composite.rs +54 -14
- data/rust/src/kernel/attribute_shapes/list.rs +2 -2
- data/rust/src/kernel/attribute_shapes/mod.rs +8 -8
- data/rust/src/kernel/attribute_shapes/scalar.rs +2 -2
- data/rust/src/kernel/cli.rs +229 -112
- data/rust/src/kernel/dispatch.rs +323 -115
- data/rust/src/kernel/dispatch_guard_tests.rs +327 -65
- data/rust/src/kernel/expr.rs +203 -86
- data/rust/src/kernel/expression_operators/arithmetic.rs +138 -25
- data/rust/src/kernel/expression_operators/comparison.rs +87 -2
- data/rust/src/kernel/expression_operators/enumeration.rs +106 -22
- data/rust/src/kernel/expression_operators/logical.rs +9 -3
- data/rust/src/kernel/expression_operators/membership.rs +3 -1
- data/rust/src/kernel/expression_operators/mod.rs +34 -21
- data/rust/src/kernel/expression_operators/pattern_match.rs +46 -11
- data/rust/src/kernel/expression_operators/positional.rs +84 -30
- data/rust/src/kernel/expression_operators/presence.rs +52 -13
- data/rust/src/kernel/expression_operators/sign_test.rs +3 -1
- data/rust/src/kernel/expression_operators/sized.rs +24 -8
- data/rust/src/kernel/expression_operators/text.rs +120 -27
- data/rust/src/kernel/expression_operators/to_string.rs +10 -4
- data/rust/src/kernel/json.rs +230 -148
- data/rust/src/kernel/mod.rs +46 -33
- data/rust/src/kernel/named_query.rs +63 -15
- data/rust/src/kernel/needs.rs +112 -25
- data/rust/src/kernel/orchestrate.rs +980 -581
- data/rust/src/kernel/orchestrate_guard_tests.rs +538 -140
- data/rust/src/kernel/pattern.rs +107 -34
- data/rust/src/kernel/query_comparators.rs +118 -41
- data/rust/src/kernel/query_ordering.rs +13 -5
- data/rust/src/kernel/read_model.rs +479 -181
- data/rust/src/kernel/reference_lookup.rs +122 -26
- data/rust/src/kernel/repository.rs +293 -66
- data/rust/src/kernel/routing.rs +58 -42
- data/rust/src/kernel/ruby_float.rs +139 -0
- data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +57 -57
- data/rust/src/kernel/vocab/entity_dispatch_order.rs +54 -54
- data/rust/src/kernel/vocab/field_hint.rs +19 -25
- data/rust/src/kernel/vocab/mod.rs +6 -6
- data/rust/src/kernel/vocab/query_comparator.rs +23 -24
- data/rust/src/kernel/vocab/refusal_template.rs +501 -297
- data/rust/src/lib.rs +50 -0
- data/rust/src/main.rs +4 -1
- data/rust/web/Cargo.toml +36 -0
- data/rust/web/clippy.toml +4 -0
- metadata +85 -15
- data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +0 -36
- data/lib/hecks/adapters/driven/google_authentication.adapter +0 -3
- data/lib/hecks/adapters/driven/google_authentication.rb +0 -99
- data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +0 -3
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +0 -32
- data/lib/hecks/ports/authentication.port +0 -6
- data/lib/hecks/ports/authentication.rb +0 -67
- data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +0 -255
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +0 -22
- data/prebuilt/verdicts-48318ca172edd21443b1ad02801c697fff100b746e828f09db987647641a5df2.json +0 -1
- data/rust/host/src/approval.rs +0 -652
data/rust/host/src/mint.rs
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
//! Mints Postgres eras, porting `lib/hecks/adapters/driven/postgres_era/
|
|
2
2
|
//! lineage/*.rb`'s SQL verbatim; this crate has no bluebook parser (ADR 0012).
|
|
3
3
|
|
|
4
|
-
use anyhow::Context;
|
|
5
4
|
use crate::journal::quote_ident;
|
|
6
5
|
use crate::reference_transform;
|
|
7
6
|
use crate::reference_validate;
|
|
8
7
|
use crate::storage_shape;
|
|
8
|
+
use anyhow::Context;
|
|
9
9
|
use serde_json::Value;
|
|
10
10
|
use sha2::{Digest, Sha256};
|
|
11
11
|
use tokio_postgres::GenericClient;
|
|
12
12
|
|
|
13
13
|
fn journal_table(domain: &str) -> String {
|
|
14
|
-
format!("hecks_journal_{}",
|
|
14
|
+
format!("hecks_journal_{}", crate::journal::snake(domain))
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
fn sequence(domain: &str) -> String {
|
|
@@ -36,12 +36,6 @@ fn head_view(domain: &str, storage_name: &str) -> String {
|
|
|
36
36
|
crate::journal::qualified_name(domain, &format!("{storage_name}_head"))
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
// `Naming.snake` — same pure syntactic transform `crate::journal::snake`
|
|
40
|
-
// already ports; reused directly rather than duplicated a second time.
|
|
41
|
-
fn storage_shape_snake(name: &str) -> String {
|
|
42
|
-
crate::journal::snake(name)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
39
|
fn text_literal(text: &str) -> String {
|
|
46
40
|
format!("'{}'", text.replace('\'', "''"))
|
|
47
41
|
}
|
|
@@ -59,7 +53,6 @@ pub struct Aggregate {
|
|
|
59
53
|
pub struct EdgeAggregate {
|
|
60
54
|
pub name: String,
|
|
61
55
|
pub was: Option<String>,
|
|
62
|
-
pub has_compute_or_rekey: bool,
|
|
63
56
|
pub compiled_state_expression: String,
|
|
64
57
|
pub compiled_id_expression: Option<String>,
|
|
65
58
|
}
|
|
@@ -87,24 +80,37 @@ pub fn parse_edges(ir: &Value, domain: &str) -> Vec<Edge> {
|
|
|
87
80
|
list.iter()
|
|
88
81
|
.filter(|edge| edge.get("domain").and_then(Value::as_str) == Some(domain))
|
|
89
82
|
.map(|edge| Edge {
|
|
90
|
-
from: edge
|
|
91
|
-
|
|
83
|
+
from: edge
|
|
84
|
+
.get("from")
|
|
85
|
+
.and_then(Value::as_str)
|
|
86
|
+
.unwrap_or_default()
|
|
87
|
+
.to_string(),
|
|
88
|
+
to: edge
|
|
89
|
+
.get("to")
|
|
90
|
+
.and_then(Value::as_str)
|
|
91
|
+
.unwrap_or_default()
|
|
92
|
+
.to_string(),
|
|
92
93
|
aggregates: edge
|
|
93
94
|
.get("aggregates")
|
|
94
95
|
.and_then(Value::as_array)
|
|
95
96
|
.map(|aggs| {
|
|
96
97
|
aggs.iter()
|
|
97
98
|
.map(|agg| EdgeAggregate {
|
|
98
|
-
name: agg
|
|
99
|
+
name: agg
|
|
100
|
+
.get("name")
|
|
101
|
+
.and_then(Value::as_str)
|
|
102
|
+
.unwrap_or_default()
|
|
103
|
+
.to_string(),
|
|
99
104
|
was: agg.get("was").and_then(Value::as_str).map(str::to_string),
|
|
100
|
-
has_compute_or_rekey: !agg.get("computes").and_then(Value::as_array).map(Vec::is_empty).unwrap_or(true)
|
|
101
|
-
|| !agg.get("rekeys").and_then(Value::as_array).map(Vec::is_empty).unwrap_or(true),
|
|
102
105
|
compiled_state_expression: agg
|
|
103
106
|
.get("compiled_state_expression")
|
|
104
107
|
.and_then(Value::as_str)
|
|
105
108
|
.unwrap_or("state")
|
|
106
109
|
.to_string(),
|
|
107
|
-
compiled_id_expression: agg
|
|
110
|
+
compiled_id_expression: agg
|
|
111
|
+
.get("compiled_id_expression")
|
|
112
|
+
.and_then(Value::as_str)
|
|
113
|
+
.map(str::to_string),
|
|
108
114
|
})
|
|
109
115
|
.collect()
|
|
110
116
|
})
|
|
@@ -146,7 +152,11 @@ pub enum BootDecision {
|
|
|
146
152
|
HoldFirst,
|
|
147
153
|
/// The latest held era differs from this binary's shape — walk the
|
|
148
154
|
/// edge chain from it and mint the next ordinal.
|
|
149
|
-
Mint {
|
|
155
|
+
Mint {
|
|
156
|
+
ordinal: i32,
|
|
157
|
+
from_ordinal: i32,
|
|
158
|
+
from_label: String,
|
|
159
|
+
},
|
|
150
160
|
/// The latest held era has no label yet — this crate has no parser to
|
|
151
161
|
/// name it, so it refuses rather than guess whether it matches.
|
|
152
162
|
LatestUnnamed { ordinal: i32 },
|
|
@@ -154,33 +164,39 @@ pub enum BootDecision {
|
|
|
154
164
|
|
|
155
165
|
impl BootDecision {
|
|
156
166
|
/// The outcome's name, as the boot log reports it.
|
|
157
|
-
pub fn name(&self) -> &'static str {
|
|
167
|
+
pub const fn name(&self) -> &'static str {
|
|
158
168
|
match self {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
169
|
+
Self::UseExisting { .. } => "use_existing",
|
|
170
|
+
Self::HoldFirst => "hold_first",
|
|
171
|
+
Self::Mint { .. } => "mint",
|
|
172
|
+
Self::LatestUnnamed { .. } => "latest_unnamed",
|
|
163
173
|
}
|
|
164
174
|
}
|
|
165
175
|
}
|
|
166
176
|
|
|
167
177
|
/// `held` must already be in ordinal order (as `journal::held_eras` returns it).
|
|
168
178
|
pub fn decide_boot_action(held: &[crate::journal::HeldEra], my_label: &str) -> BootDecision {
|
|
169
|
-
if let Some(matching) = held
|
|
170
|
-
|
|
179
|
+
if let Some(matching) = held
|
|
180
|
+
.iter()
|
|
181
|
+
.find(|held_era| held_era.label.as_deref() == Some(my_label))
|
|
182
|
+
{
|
|
183
|
+
return BootDecision::UseExisting {
|
|
184
|
+
ordinal: matching.ordinal,
|
|
185
|
+
};
|
|
171
186
|
}
|
|
172
|
-
|
|
187
|
+
let Some(latest) = held.last() else {
|
|
173
188
|
return BootDecision::HoldFirst;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
189
|
+
};
|
|
190
|
+
latest.label.as_ref().map_or(
|
|
191
|
+
BootDecision::LatestUnnamed {
|
|
192
|
+
ordinal: latest.ordinal,
|
|
193
|
+
},
|
|
194
|
+
|label| BootDecision::Mint {
|
|
179
195
|
ordinal: latest.ordinal + 1,
|
|
180
196
|
from_ordinal: latest.ordinal,
|
|
181
197
|
from_label: label.clone(),
|
|
182
198
|
},
|
|
183
|
-
|
|
199
|
+
)
|
|
184
200
|
}
|
|
185
201
|
|
|
186
202
|
async fn provisioner<C: GenericClient>(client: &C, journal: &str) -> anyhow::Result<bool> {
|
|
@@ -190,13 +206,14 @@ async fn provisioner<C: GenericClient>(client: &C, journal: &str) -> anyhow::Res
|
|
|
190
206
|
&[&journal],
|
|
191
207
|
)
|
|
192
208
|
.await?;
|
|
193
|
-
Ok(
|
|
194
|
-
None => true,
|
|
195
|
-
Some(row) => row.get::<_, bool>("owned"),
|
|
196
|
-
})
|
|
209
|
+
Ok(row.is_none_or(|row| row.get::<_, bool>("owned")))
|
|
197
210
|
}
|
|
198
211
|
|
|
199
|
-
async fn partition_attached<C: GenericClient>(
|
|
212
|
+
async fn partition_attached<C: GenericClient>(
|
|
213
|
+
client: &C,
|
|
214
|
+
partition_name: &str,
|
|
215
|
+
journal: &str,
|
|
216
|
+
) -> anyhow::Result<bool> {
|
|
200
217
|
let row = client
|
|
201
218
|
.query_opt(
|
|
202
219
|
"SELECT 1 FROM pg_inherits i \
|
|
@@ -210,24 +227,36 @@ async fn partition_attached<C: GenericClient>(client: &C, partition_name: &str,
|
|
|
210
227
|
Ok(row.is_some())
|
|
211
228
|
}
|
|
212
229
|
|
|
213
|
-
async fn ensure_partition<C: GenericClient>(
|
|
230
|
+
async fn ensure_partition<C: GenericClient>(
|
|
231
|
+
client: &C,
|
|
232
|
+
domain: &str,
|
|
233
|
+
era: i32,
|
|
234
|
+
) -> anyhow::Result<()> {
|
|
214
235
|
let journal = journal_table(domain);
|
|
215
236
|
let part = partition(domain, era);
|
|
216
237
|
if partition_attached(client, &part, &journal).await? {
|
|
217
238
|
return Ok(());
|
|
218
239
|
}
|
|
219
240
|
client
|
|
220
|
-
.batch_execute(&format!(
|
|
241
|
+
.batch_execute(&format!(
|
|
242
|
+
"CREATE TABLE IF NOT EXISTS {} (LIKE {} INCLUDING DEFAULTS)",
|
|
243
|
+
quote_ident(&part),
|
|
244
|
+
quote_ident(&journal)
|
|
245
|
+
))
|
|
221
246
|
.await?;
|
|
222
247
|
client
|
|
223
|
-
.batch_execute(&format!(
|
|
248
|
+
.batch_execute(&format!(
|
|
249
|
+
"ALTER TABLE {} ATTACH PARTITION {} FOR VALUES IN ({era})",
|
|
250
|
+
quote_ident(&journal),
|
|
251
|
+
quote_ident(&part)
|
|
252
|
+
))
|
|
224
253
|
.await?;
|
|
225
254
|
Ok(())
|
|
226
255
|
}
|
|
227
256
|
|
|
228
257
|
async fn install_transforms<C: GenericClient>(client: &C) -> anyhow::Result<()> {
|
|
229
258
|
const FUNCTIONS: &[&str] = &[
|
|
230
|
-
r
|
|
259
|
+
r"CREATE OR REPLACE FUNCTION hecks_tr_extract(state jsonb, path text[], OUT remaining jsonb, OUT value jsonb, OUT present boolean)
|
|
231
260
|
LANGUAGE plpgsql IMMUTABLE AS $fn$
|
|
232
261
|
DECLARE parent jsonb; leaf text;
|
|
233
262
|
BEGIN
|
|
@@ -253,8 +282,8 @@ BEGIN
|
|
|
253
282
|
remaining := jsonb_set(state, path[1:array_upper(path, 1) - 1], parent);
|
|
254
283
|
END IF;
|
|
255
284
|
END IF;
|
|
256
|
-
END $fn$"
|
|
257
|
-
r
|
|
285
|
+
END $fn$",
|
|
286
|
+
r"CREATE OR REPLACE FUNCTION hecks_tr_insert(state jsonb, path text[], value jsonb, rule_label text) RETURNS jsonb
|
|
258
287
|
LANGUAGE plpgsql IMMUTABLE AS $fn$
|
|
259
288
|
BEGIN
|
|
260
289
|
IF array_length(path, 1) = 1 THEN
|
|
@@ -268,22 +297,22 @@ BEGIN
|
|
|
268
297
|
state := state || jsonb_build_object(path[1], '{}'::jsonb);
|
|
269
298
|
END IF;
|
|
270
299
|
RETURN jsonb_set(state, path, value);
|
|
271
|
-
END $fn$"
|
|
272
|
-
r
|
|
300
|
+
END $fn$",
|
|
301
|
+
r"CREATE OR REPLACE FUNCTION hecks_tr_rename(state jsonb, old_name text, new_name text) RETURNS jsonb
|
|
273
302
|
LANGUAGE sql IMMUTABLE AS $fn$
|
|
274
303
|
SELECT CASE WHEN state ? old_name
|
|
275
304
|
THEN (state - old_name) || jsonb_build_object(new_name, state -> old_name)
|
|
276
305
|
ELSE state END
|
|
277
|
-
$fn$"
|
|
278
|
-
r
|
|
306
|
+
$fn$",
|
|
307
|
+
r"CREATE OR REPLACE FUNCTION hecks_tr_move(state jsonb, from_path text[], to_path text[], rule_label text) RETURNS jsonb
|
|
279
308
|
LANGUAGE plpgsql IMMUTABLE AS $fn$
|
|
280
309
|
DECLARE extracted record;
|
|
281
310
|
BEGIN
|
|
282
311
|
SELECT * INTO extracted FROM hecks_tr_extract(state, from_path);
|
|
283
312
|
IF NOT extracted.present THEN RETURN state; END IF;
|
|
284
313
|
RETURN hecks_tr_insert(extracted.remaining, to_path, extracted.value, rule_label);
|
|
285
|
-
END $fn$"
|
|
286
|
-
r
|
|
314
|
+
END $fn$",
|
|
315
|
+
r"CREATE OR REPLACE FUNCTION hecks_tr_convert(state jsonb, from_path text[], to_path text[], pairs jsonb, from_label text, rule_label text) RETURNS jsonb
|
|
287
316
|
LANGUAGE plpgsql IMMUTABLE AS $fn$
|
|
288
317
|
DECLARE extracted record; pair jsonb;
|
|
289
318
|
BEGIN
|
|
@@ -296,14 +325,14 @@ BEGIN
|
|
|
296
325
|
END LOOP;
|
|
297
326
|
RAISE EXCEPTION 'cannot translate %: % has no mapping in its convert''s values: table. Add % => ... to cover it.',
|
|
298
327
|
from_label, extracted.value, extracted.value;
|
|
299
|
-
END $fn$"
|
|
300
|
-
r
|
|
328
|
+
END $fn$",
|
|
329
|
+
r"CREATE OR REPLACE FUNCTION hecks_tr_drop(state jsonb, path text[]) RETURNS jsonb
|
|
301
330
|
LANGUAGE plpgsql IMMUTABLE AS $fn$
|
|
302
331
|
DECLARE extracted record;
|
|
303
332
|
BEGIN
|
|
304
333
|
SELECT * INTO extracted FROM hecks_tr_extract(state, path);
|
|
305
334
|
RETURN extracted.remaining;
|
|
306
|
-
END $fn$"
|
|
335
|
+
END $fn$",
|
|
307
336
|
];
|
|
308
337
|
for sql in FUNCTIONS {
|
|
309
338
|
client.batch_execute(sql).await?;
|
|
@@ -327,9 +356,15 @@ pub async fn ensure_base<C: GenericClient>(client: &C, domain: &str) -> anyhow::
|
|
|
327
356
|
PRIMARY KEY (domain, ordinal))",
|
|
328
357
|
)
|
|
329
358
|
.await?;
|
|
330
|
-
client
|
|
331
|
-
|
|
332
|
-
|
|
359
|
+
client
|
|
360
|
+
.batch_execute("ALTER TABLE hecks_eras ADD COLUMN IF NOT EXISTS held_digest text")
|
|
361
|
+
.await?;
|
|
362
|
+
client
|
|
363
|
+
.batch_execute("ALTER TABLE hecks_eras ADD COLUMN IF NOT EXISTS held_projection jsonb")
|
|
364
|
+
.await?;
|
|
365
|
+
client
|
|
366
|
+
.batch_execute("ALTER TABLE hecks_eras ADD COLUMN IF NOT EXISTS canon_form int")
|
|
367
|
+
.await?;
|
|
333
368
|
client
|
|
334
369
|
.batch_execute(
|
|
335
370
|
"CREATE TABLE IF NOT EXISTS hecks_era_texts (\
|
|
@@ -340,18 +375,19 @@ pub async fn ensure_base<C: GenericClient>(client: &C, domain: &str) -> anyhow::
|
|
|
340
375
|
.await?;
|
|
341
376
|
client
|
|
342
377
|
.batch_execute(
|
|
343
|
-
"CREATE TABLE IF NOT EXISTS
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
approved_at timestamptz NOT NULL DEFAULT now())",
|
|
378
|
+
"CREATE TABLE IF NOT EXISTS hecks_backfill_progress (\
|
|
379
|
+
target text PRIMARY KEY, cursor text, completed boolean NOT NULL DEFAULT false, \
|
|
380
|
+
updated_at timestamptz NOT NULL DEFAULT now())",
|
|
347
381
|
)
|
|
348
382
|
.await?;
|
|
349
|
-
client.batch_execute("CREATE TABLE IF NOT EXISTS hecks_backfill_progress (\
|
|
350
|
-
target text PRIMARY KEY, cursor text, completed boolean NOT NULL DEFAULT false, \
|
|
351
|
-
updated_at timestamptz NOT NULL DEFAULT now())").await?;
|
|
352
383
|
|
|
353
384
|
let seq = sequence(domain);
|
|
354
|
-
client
|
|
385
|
+
client
|
|
386
|
+
.batch_execute(&format!(
|
|
387
|
+
"CREATE SEQUENCE IF NOT EXISTS {}",
|
|
388
|
+
quote_ident(&seq)
|
|
389
|
+
))
|
|
390
|
+
.await?;
|
|
355
391
|
client
|
|
356
392
|
.batch_execute(&format!(
|
|
357
393
|
"CREATE TABLE IF NOT EXISTS {} (\
|
|
@@ -373,10 +409,14 @@ pub async fn ensure_base<C: GenericClient>(client: &C, domain: &str) -> anyhow::
|
|
|
373
409
|
&[&journal],
|
|
374
410
|
)
|
|
375
411
|
.await?
|
|
376
|
-
.
|
|
377
|
-
.unwrap_or(true);
|
|
412
|
+
.is_none_or(|row| row.get::<_, bool>(0));
|
|
378
413
|
if relacl_null {
|
|
379
|
-
client
|
|
414
|
+
client
|
|
415
|
+
.batch_execute(&format!(
|
|
416
|
+
"REVOKE UPDATE, DELETE ON {} FROM PUBLIC",
|
|
417
|
+
quote_ident(&journal)
|
|
418
|
+
))
|
|
419
|
+
.await?;
|
|
380
420
|
}
|
|
381
421
|
|
|
382
422
|
let current = client
|
|
@@ -385,12 +425,27 @@ pub async fn ensure_base<C: GenericClient>(client: &C, domain: &str) -> anyhow::
|
|
|
385
425
|
&[&journal],
|
|
386
426
|
)
|
|
387
427
|
.await?;
|
|
388
|
-
let (rowsecurity, forcerowsecurity) = current.
|
|
428
|
+
let (rowsecurity, forcerowsecurity) = current.map_or((false, false), |row| {
|
|
429
|
+
(
|
|
430
|
+
row.get::<_, bool>("relrowsecurity"),
|
|
431
|
+
row.get::<_, bool>("relforcerowsecurity"),
|
|
432
|
+
)
|
|
433
|
+
});
|
|
389
434
|
if !rowsecurity {
|
|
390
|
-
client
|
|
435
|
+
client
|
|
436
|
+
.batch_execute(&format!(
|
|
437
|
+
"ALTER TABLE {} ENABLE ROW LEVEL SECURITY",
|
|
438
|
+
quote_ident(&journal)
|
|
439
|
+
))
|
|
440
|
+
.await?;
|
|
391
441
|
}
|
|
392
442
|
if !forcerowsecurity {
|
|
393
|
-
client
|
|
443
|
+
client
|
|
444
|
+
.batch_execute(&format!(
|
|
445
|
+
"ALTER TABLE {} FORCE ROW LEVEL SECURITY",
|
|
446
|
+
quote_ident(&journal)
|
|
447
|
+
))
|
|
448
|
+
.await?;
|
|
394
449
|
}
|
|
395
450
|
|
|
396
451
|
install_transforms(client).await?;
|
|
@@ -399,15 +454,27 @@ pub async fn ensure_base<C: GenericClient>(client: &C, domain: &str) -> anyhow::
|
|
|
399
454
|
|
|
400
455
|
const CHUNK_SIZE: i64 = 5_000;
|
|
401
456
|
|
|
402
|
-
async fn backfill_progress<C: GenericClient>(
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
457
|
+
async fn backfill_progress<C: GenericClient>(
|
|
458
|
+
client: &C,
|
|
459
|
+
target: &str,
|
|
460
|
+
) -> anyhow::Result<(Option<String>, bool)> {
|
|
461
|
+
let row = client
|
|
462
|
+
.query_opt(
|
|
463
|
+
"SELECT cursor, completed FROM hecks_backfill_progress WHERE target = $1",
|
|
464
|
+
&[&target],
|
|
465
|
+
)
|
|
466
|
+
.await?;
|
|
467
|
+
Ok(row.map_or((None, false), |row| {
|
|
468
|
+
(row.get("cursor"), row.get("completed"))
|
|
469
|
+
}))
|
|
408
470
|
}
|
|
409
471
|
|
|
410
|
-
async fn upsert_backfill_progress<C: GenericClient>(
|
|
472
|
+
async fn upsert_backfill_progress<C: GenericClient>(
|
|
473
|
+
client: &C,
|
|
474
|
+
target: &str,
|
|
475
|
+
cursor: Option<&str>,
|
|
476
|
+
completed: bool,
|
|
477
|
+
) -> anyhow::Result<()> {
|
|
411
478
|
client
|
|
412
479
|
.execute(
|
|
413
480
|
"INSERT INTO hecks_backfill_progress (target, cursor, completed, updated_at) VALUES ($1, $2, $3, now()) \
|
|
@@ -420,8 +487,16 @@ async fn upsert_backfill_progress<C: GenericClient>(client: &C, target: &str, cu
|
|
|
420
487
|
|
|
421
488
|
/// One chunk of `head_snapshot`'s backfill, SAVEPOINT-nested since this
|
|
422
489
|
/// always runs inside the mint transaction `mint_era` already opened.
|
|
423
|
-
async fn run_backfill_chunk<C: GenericClient>(
|
|
424
|
-
client
|
|
490
|
+
async fn run_backfill_chunk<C: GenericClient>(
|
|
491
|
+
client: &C,
|
|
492
|
+
domain: &str,
|
|
493
|
+
storage_name: &str,
|
|
494
|
+
era: i32,
|
|
495
|
+
target: &str,
|
|
496
|
+
) -> anyhow::Result<bool> {
|
|
497
|
+
client
|
|
498
|
+
.batch_execute("SAVEPOINT hecks_backfill_chunk")
|
|
499
|
+
.await?;
|
|
425
500
|
let result: anyhow::Result<bool> = async {
|
|
426
501
|
client.execute("SELECT pg_advisory_xact_lock(hashtext('hecks_field_cache:' || $1))", &[&target]).await?;
|
|
427
502
|
let (cursor, completed) = backfill_progress(client, target).await?;
|
|
@@ -463,20 +538,35 @@ async fn run_backfill_chunk<C: GenericClient>(client: &C, domain: &str, storage_
|
|
|
463
538
|
.await?;
|
|
464
539
|
}
|
|
465
540
|
let done = (rows.len() as i64) < CHUNK_SIZE;
|
|
466
|
-
let
|
|
467
|
-
|
|
541
|
+
if let Some(last) = rows.last() {
|
|
542
|
+
let last_cursor: String = last.get("id");
|
|
543
|
+
upsert_backfill_progress(client, target, Some(&last_cursor), done).await?;
|
|
544
|
+
}
|
|
468
545
|
Ok(done)
|
|
469
546
|
}
|
|
470
547
|
.await;
|
|
471
548
|
|
|
472
549
|
match &result {
|
|
473
|
-
Ok(_) =>
|
|
474
|
-
|
|
550
|
+
Ok(_) => {
|
|
551
|
+
client
|
|
552
|
+
.batch_execute("RELEASE SAVEPOINT hecks_backfill_chunk")
|
|
553
|
+
.await?;
|
|
554
|
+
}
|
|
555
|
+
Err(_) => {
|
|
556
|
+
client
|
|
557
|
+
.batch_execute("ROLLBACK TO SAVEPOINT hecks_backfill_chunk")
|
|
558
|
+
.await?;
|
|
559
|
+
}
|
|
475
560
|
}
|
|
476
561
|
result
|
|
477
562
|
}
|
|
478
563
|
|
|
479
|
-
async fn backfill_head_snapshot<C: GenericClient>(
|
|
564
|
+
async fn backfill_head_snapshot<C: GenericClient>(
|
|
565
|
+
client: &C,
|
|
566
|
+
domain: &str,
|
|
567
|
+
storage_name: &str,
|
|
568
|
+
era: i32,
|
|
569
|
+
) -> anyhow::Result<()> {
|
|
480
570
|
let target = head_snapshot(domain, storage_name, era);
|
|
481
571
|
loop {
|
|
482
572
|
if run_backfill_chunk(client, domain, storage_name, era, &target).await? {
|
|
@@ -501,36 +591,61 @@ pub async fn adopt_head_snapshots<C: GenericClient>(
|
|
|
501
591
|
aggregates: &[Aggregate],
|
|
502
592
|
era: i32,
|
|
503
593
|
) -> anyhow::Result<()> {
|
|
504
|
-
client
|
|
594
|
+
client
|
|
595
|
+
.batch_execute("BEGIN")
|
|
596
|
+
.await
|
|
597
|
+
.context("opening a transaction to adopt head snapshots")?;
|
|
505
598
|
let result: anyhow::Result<()> = async {
|
|
506
599
|
for aggregate in aggregates {
|
|
507
|
-
create_head_snapshot(client, domain, &aggregate.storage_name, era)
|
|
508
|
-
|
|
509
|
-
|
|
600
|
+
create_head_snapshot(client, domain, &aggregate.storage_name, era)
|
|
601
|
+
.await
|
|
602
|
+
.with_context(|| {
|
|
603
|
+
format!(
|
|
604
|
+
"provisioning {domain}'s {} head snapshot for era {era}",
|
|
605
|
+
aggregate.storage_name
|
|
606
|
+
)
|
|
607
|
+
})?;
|
|
510
608
|
}
|
|
511
609
|
Ok(())
|
|
512
610
|
}
|
|
513
611
|
.await;
|
|
514
612
|
match &result {
|
|
515
|
-
Ok(
|
|
613
|
+
Ok(()) => client.batch_execute("COMMIT").await?,
|
|
516
614
|
Err(_) => client.batch_execute("ROLLBACK").await?,
|
|
517
615
|
}
|
|
518
616
|
result
|
|
519
617
|
}
|
|
520
618
|
|
|
521
|
-
async fn ensure_head_snapshot<C: GenericClient>(
|
|
619
|
+
async fn ensure_head_snapshot<C: GenericClient>(
|
|
620
|
+
client: &C,
|
|
621
|
+
domain: &str,
|
|
622
|
+
storage_name: &str,
|
|
623
|
+
era: i32,
|
|
624
|
+
) -> anyhow::Result<()> {
|
|
522
625
|
create_head_snapshot(client, domain, storage_name, era).await?;
|
|
523
626
|
backfill_head_snapshot(client, domain, storage_name, era).await
|
|
524
627
|
}
|
|
525
628
|
|
|
526
629
|
/// The create half, shared by minting and adopting. Columns match Ruby's
|
|
527
630
|
/// own shape, so a Ruby runtime can still compile a head view over this table.
|
|
528
|
-
async fn create_head_snapshot<C: GenericClient>(
|
|
631
|
+
async fn create_head_snapshot<C: GenericClient>(
|
|
632
|
+
client: &C,
|
|
633
|
+
domain: &str,
|
|
634
|
+
storage_name: &str,
|
|
635
|
+
era: i32,
|
|
636
|
+
) -> anyhow::Result<()> {
|
|
529
637
|
let name = head_snapshot(domain, storage_name, era);
|
|
530
638
|
if !table_exists(client, &name).await? {
|
|
531
|
-
client
|
|
639
|
+
client
|
|
640
|
+
.batch_execute("SAVEPOINT hecks_head_snapshot")
|
|
641
|
+
.await?;
|
|
532
642
|
let result: anyhow::Result<()> = async {
|
|
533
|
-
client
|
|
643
|
+
client
|
|
644
|
+
.execute(
|
|
645
|
+
"SELECT pg_advisory_xact_lock(hashtext('hecks_head_snapshot:' || $1))",
|
|
646
|
+
&[&name],
|
|
647
|
+
)
|
|
648
|
+
.await?;
|
|
534
649
|
if !table_exists(client, &name).await? {
|
|
535
650
|
client
|
|
536
651
|
.batch_execute(&format!(
|
|
@@ -544,8 +659,16 @@ async fn create_head_snapshot<C: GenericClient>(client: &C, domain: &str, storag
|
|
|
544
659
|
}
|
|
545
660
|
.await;
|
|
546
661
|
match &result {
|
|
547
|
-
Ok(
|
|
548
|
-
|
|
662
|
+
Ok(()) => {
|
|
663
|
+
client
|
|
664
|
+
.batch_execute("RELEASE SAVEPOINT hecks_head_snapshot")
|
|
665
|
+
.await?;
|
|
666
|
+
}
|
|
667
|
+
Err(_) => {
|
|
668
|
+
client
|
|
669
|
+
.batch_execute("ROLLBACK TO SAVEPOINT hecks_head_snapshot")
|
|
670
|
+
.await?;
|
|
671
|
+
}
|
|
549
672
|
}
|
|
550
673
|
result?;
|
|
551
674
|
}
|
|
@@ -559,18 +682,30 @@ fn names_by_era(current_name: &str, edges: &[&Edge]) -> (Vec<String>, Vec<String
|
|
|
559
682
|
current[edges.len()] = current_name.to_string();
|
|
560
683
|
for index in (0..edges.len()).rev() {
|
|
561
684
|
let declared = edges[index].for_aggregate(¤t[index + 1]);
|
|
562
|
-
current[index] = declared
|
|
685
|
+
current[index] = declared
|
|
686
|
+
.and_then(|d| d.was.clone())
|
|
687
|
+
.unwrap_or_else(|| current[index + 1].clone());
|
|
563
688
|
}
|
|
564
|
-
let storage = current
|
|
689
|
+
let storage = current
|
|
690
|
+
.iter()
|
|
691
|
+
.map(|name| crate::journal::snake(name))
|
|
692
|
+
.collect();
|
|
565
693
|
(current, storage)
|
|
566
694
|
}
|
|
567
695
|
|
|
568
|
-
fn ancestor_tail_sql(
|
|
696
|
+
fn ancestor_tail_sql(
|
|
697
|
+
domain: &str,
|
|
698
|
+
era: i32,
|
|
699
|
+
storage_names: &[String],
|
|
700
|
+
watermarks: &std::collections::HashMap<i32, Option<i64>>,
|
|
701
|
+
) -> String {
|
|
569
702
|
let journal = quote_ident(&journal_table(domain));
|
|
570
703
|
(1..era)
|
|
571
704
|
.map(|ancestor| {
|
|
572
705
|
let cut = watermarks.get(&(ancestor + 1)).copied().flatten();
|
|
573
|
-
let cut_clause = cut
|
|
706
|
+
let cut_clause = cut
|
|
707
|
+
.map(|c| format!(" AND ordinal <= {c}"))
|
|
708
|
+
.unwrap_or_default();
|
|
574
709
|
format!(
|
|
575
710
|
"SELECT ordinal, era, aggregate, aggregate_id, operation, state FROM {journal} \
|
|
576
711
|
WHERE era = {ancestor} AND aggregate = {}{cut_clause}",
|
|
@@ -590,7 +725,13 @@ fn latest_per_id(tail: &str) -> String {
|
|
|
590
725
|
|
|
591
726
|
// Each edge CTE is `MATERIALIZED` — unfenced, Postgres re-inlines every edge's
|
|
592
727
|
// expression at each read of `state`, and the plan grows exponentially with the chain.
|
|
593
|
-
fn chain_sql(
|
|
728
|
+
fn chain_sql(
|
|
729
|
+
domain: &str,
|
|
730
|
+
current_name: &str,
|
|
731
|
+
era: i32,
|
|
732
|
+
edges: &[&Edge],
|
|
733
|
+
watermarks: &std::collections::HashMap<i32, Option<i64>>,
|
|
734
|
+
) -> String {
|
|
594
735
|
chain_sql_with(domain, current_name, era, edges, watermarks, true)
|
|
595
736
|
}
|
|
596
737
|
|
|
@@ -602,7 +743,11 @@ fn chain_sql_with(
|
|
|
602
743
|
watermarks: &std::collections::HashMap<i32, Option<i64>>,
|
|
603
744
|
materialize_edges: bool,
|
|
604
745
|
) -> String {
|
|
605
|
-
let fence = if materialize_edges {
|
|
746
|
+
let fence = if materialize_edges {
|
|
747
|
+
"MATERIALIZED "
|
|
748
|
+
} else {
|
|
749
|
+
""
|
|
750
|
+
};
|
|
606
751
|
let (current_names, storage_names) = names_by_era(current_name, edges);
|
|
607
752
|
let tail = latest_per_id(&ancestor_tail_sql(domain, era, &storage_names, watermarks));
|
|
608
753
|
|
|
@@ -611,15 +756,18 @@ fn chain_sql_with(
|
|
|
611
756
|
.enumerate()
|
|
612
757
|
.map(|(index, edge)| {
|
|
613
758
|
let declared = edge.for_aggregate(¤t_names[index + 1]);
|
|
614
|
-
let expression = declared.
|
|
759
|
+
let expression = declared.map_or_else(|| "state".to_string(), |d| d.compiled_state_expression.clone());
|
|
615
760
|
let guard = format!("era <= {} AND operation = 'save'", index + 1);
|
|
616
|
-
let id_column =
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
761
|
+
let id_column = declared
|
|
762
|
+
.and_then(|d| d.compiled_id_expression.as_deref())
|
|
763
|
+
.map_or_else(
|
|
764
|
+
|| "aggregate_id".to_string(),
|
|
765
|
+
|id_expression| {
|
|
766
|
+
format!(
|
|
767
|
+
"CASE WHEN {guard} THEN {id_expression} ELSE aggregate_id END AS aggregate_id"
|
|
768
|
+
)
|
|
769
|
+
},
|
|
770
|
+
);
|
|
623
771
|
let from = if index == 0 { "tail".to_string() } else { format!("edge_{index}") };
|
|
624
772
|
format!(
|
|
625
773
|
"edge_{} AS {fence}(SELECT ordinal, era, {id_column}, operation, CASE WHEN {guard} THEN {expression} ELSE state END AS state FROM {from})",
|
|
@@ -632,7 +780,10 @@ fn chain_sql_with(
|
|
|
632
780
|
}
|
|
633
781
|
|
|
634
782
|
/// Reduces a chain SQL to one row per id: the latest entry, only if it was a save.
|
|
635
|
-
async fn latest_of<C: GenericClient>(
|
|
783
|
+
async fn latest_of<C: GenericClient>(
|
|
784
|
+
client: &C,
|
|
785
|
+
inner_sql: &str,
|
|
786
|
+
) -> anyhow::Result<std::collections::HashMap<String, Value>> {
|
|
636
787
|
let sql = format!(
|
|
637
788
|
"SELECT aggregate_id, state FROM (\
|
|
638
789
|
SELECT DISTINCT ON (aggregate_id) aggregate_id, operation, state \
|
|
@@ -640,7 +791,15 @@ async fn latest_of<C: GenericClient>(client: &C, inner_sql: &str) -> anyhow::Res
|
|
|
640
791
|
) latest WHERE operation = 'save'"
|
|
641
792
|
);
|
|
642
793
|
let rows = client.query(&sql, &[]).await?;
|
|
643
|
-
Ok(rows
|
|
794
|
+
Ok(rows
|
|
795
|
+
.into_iter()
|
|
796
|
+
.map(|row| {
|
|
797
|
+
(
|
|
798
|
+
row.get::<_, String>("aggregate_id"),
|
|
799
|
+
row.get::<_, Value>("state"),
|
|
800
|
+
)
|
|
801
|
+
})
|
|
802
|
+
.collect())
|
|
644
803
|
}
|
|
645
804
|
|
|
646
805
|
/// The translated tail at era `era`, latest per id, saves only — the audit's `after`.
|
|
@@ -652,7 +811,11 @@ async fn translated_latest<C: GenericClient>(
|
|
|
652
811
|
edges: &[&Edge],
|
|
653
812
|
watermarks: &std::collections::HashMap<i32, Option<i64>>,
|
|
654
813
|
) -> anyhow::Result<std::collections::HashMap<String, Value>> {
|
|
655
|
-
latest_of(
|
|
814
|
+
latest_of(
|
|
815
|
+
client,
|
|
816
|
+
&chain_sql(domain, current_name, era, edges, watermarks),
|
|
817
|
+
)
|
|
818
|
+
.await
|
|
656
819
|
}
|
|
657
820
|
|
|
658
821
|
/// The untranslated ancestor tail, latest per id — the "before" side of the
|
|
@@ -670,12 +833,21 @@ async fn ancestor_latest<C: GenericClient>(
|
|
|
670
833
|
if tail.is_empty() {
|
|
671
834
|
return Ok(std::collections::HashMap::new());
|
|
672
835
|
}
|
|
673
|
-
latest_of(
|
|
836
|
+
latest_of(
|
|
837
|
+
client,
|
|
838
|
+
&format!("SELECT ordinal, era, aggregate_id, operation, state FROM ({tail}) tail_rows"),
|
|
839
|
+
)
|
|
840
|
+
.await
|
|
674
841
|
}
|
|
675
842
|
|
|
676
843
|
/// One aggregate's raw rule JSON within one edge; `reference_transform::translate`
|
|
677
844
|
/// needs the declarative rules themselves, not just the compiled SQL.
|
|
678
|
-
fn raw_declared<'a>(
|
|
845
|
+
fn raw_declared<'a>(
|
|
846
|
+
raw_edges: &'a [Value],
|
|
847
|
+
domain: &str,
|
|
848
|
+
edge: &Edge,
|
|
849
|
+
aggregate_name: &str,
|
|
850
|
+
) -> Option<&'a Value> {
|
|
679
851
|
raw_edges
|
|
680
852
|
.iter()
|
|
681
853
|
.find(|candidate| {
|
|
@@ -684,7 +856,10 @@ fn raw_declared<'a>(raw_edges: &'a [Value], domain: &str, edge: &Edge, aggregate
|
|
|
684
856
|
&& candidate.get("to").and_then(Value::as_str) == Some(edge.to.as_str())
|
|
685
857
|
})
|
|
686
858
|
.and_then(|raw_edge| raw_edge.get("aggregates").and_then(Value::as_array))
|
|
687
|
-
.and_then(|aggs|
|
|
859
|
+
.and_then(|aggs| {
|
|
860
|
+
aggs.iter()
|
|
861
|
+
.find(|agg| agg.get("name").and_then(Value::as_str) == Some(aggregate_name))
|
|
862
|
+
})
|
|
688
863
|
}
|
|
689
864
|
|
|
690
865
|
/// Layer 2 of the mint audit for one aggregate: per-rule value preservation
|
|
@@ -693,6 +868,7 @@ fn raw_declared<'a>(raw_edges: &'a [Value], domain: &str, edge: &Edge, aggregate
|
|
|
693
868
|
///
|
|
694
869
|
/// `declared` is the raw rule JSON for the edge being minted this boot only —
|
|
695
870
|
/// earlier edges in a multi-hop drift were already minted and audited.
|
|
871
|
+
#[allow(clippy::too_many_arguments)]
|
|
696
872
|
async fn audit_layer_two<C: GenericClient>(
|
|
697
873
|
client: &C,
|
|
698
874
|
domain: &str,
|
|
@@ -705,14 +881,28 @@ async fn audit_layer_two<C: GenericClient>(
|
|
|
705
881
|
violations: &mut Vec<String>,
|
|
706
882
|
) -> anyhow::Result<()> {
|
|
707
883
|
let before = if edges.len() > 1 {
|
|
708
|
-
translated_latest(
|
|
884
|
+
translated_latest(
|
|
885
|
+
client,
|
|
886
|
+
domain,
|
|
887
|
+
&aggregate.name,
|
|
888
|
+
ordinal,
|
|
889
|
+
&edges[..edges.len() - 1],
|
|
890
|
+
watermarks,
|
|
891
|
+
)
|
|
892
|
+
.await?
|
|
709
893
|
} else {
|
|
710
894
|
ancestor_latest(client, domain, &aggregate.name, ordinal, edges, watermarks).await?
|
|
711
895
|
};
|
|
712
896
|
|
|
713
|
-
let last_edge = edges.last()
|
|
897
|
+
let Some(last_edge) = edges.last() else {
|
|
898
|
+
return Ok(()); // nothing to audit across zero edges
|
|
899
|
+
};
|
|
714
900
|
let declared = raw_declared(raw_edges, domain, last_edge, &aggregate.name);
|
|
715
|
-
let rekeyed = declared.
|
|
901
|
+
let rekeyed = declared.is_some_and(|d| {
|
|
902
|
+
!d.get("rekeys")
|
|
903
|
+
.and_then(Value::as_array)
|
|
904
|
+
.is_none_or(Vec::is_empty)
|
|
905
|
+
});
|
|
716
906
|
|
|
717
907
|
if rekeyed {
|
|
718
908
|
if before.len() != after.len() {
|
|
@@ -725,8 +915,14 @@ async fn audit_layer_two<C: GenericClient>(
|
|
|
725
915
|
));
|
|
726
916
|
}
|
|
727
917
|
} else {
|
|
728
|
-
let mut lost: Vec<&String> = before
|
|
729
|
-
|
|
918
|
+
let mut lost: Vec<&String> = before
|
|
919
|
+
.keys()
|
|
920
|
+
.filter(|id| !after.contains_key(*id))
|
|
921
|
+
.collect();
|
|
922
|
+
let mut gained: Vec<&String> = after
|
|
923
|
+
.keys()
|
|
924
|
+
.filter(|id| !before.contains_key(*id))
|
|
925
|
+
.collect();
|
|
730
926
|
if !lost.is_empty() || !gained.is_empty() {
|
|
731
927
|
lost.sort();
|
|
732
928
|
gained.sort();
|
|
@@ -737,31 +933,20 @@ async fn audit_layer_two<C: GenericClient>(
|
|
|
737
933
|
}
|
|
738
934
|
}
|
|
739
935
|
|
|
740
|
-
let Some(declared) = declared else {
|
|
936
|
+
let Some(declared) = declared else {
|
|
937
|
+
return Ok(());
|
|
938
|
+
};
|
|
741
939
|
if rekeyed {
|
|
742
940
|
return Ok(());
|
|
743
941
|
}
|
|
744
942
|
|
|
745
|
-
let compute_tops: std::collections::HashSet<String> = declared
|
|
746
|
-
.get("computes")
|
|
747
|
-
.and_then(Value::as_array)
|
|
748
|
-
.map(|computes| {
|
|
749
|
-
computes
|
|
750
|
-
.iter()
|
|
751
|
-
.flat_map(|c| [c.get("from"), c.get("to")])
|
|
752
|
-
.flatten()
|
|
753
|
-
.filter_map(Value::as_str)
|
|
754
|
-
.map(|path| path.split('.').next().unwrap_or(path).to_string())
|
|
755
|
-
.collect()
|
|
756
|
-
})
|
|
757
|
-
.unwrap_or_default();
|
|
758
|
-
|
|
759
943
|
for (id, state) in &before {
|
|
760
|
-
let Some(actual_after) = after.get(id) else {
|
|
944
|
+
let Some(actual_after) = after.get(id) else {
|
|
945
|
+
continue;
|
|
946
|
+
};
|
|
761
947
|
let expected = reference_transform::translate(declared, state)?;
|
|
762
|
-
let
|
|
763
|
-
|
|
764
|
-
if expected != actual {
|
|
948
|
+
let actual = actual_after;
|
|
949
|
+
if &expected != actual {
|
|
765
950
|
let mut diverged: Vec<String> = expected
|
|
766
951
|
.as_object()
|
|
767
952
|
.into_iter()
|
|
@@ -772,23 +957,22 @@ async fn audit_layer_two<C: GenericClient>(
|
|
|
772
957
|
.collect();
|
|
773
958
|
diverged.sort();
|
|
774
959
|
diverged.dedup();
|
|
775
|
-
violations.push(format!(
|
|
960
|
+
violations.push(format!(
|
|
961
|
+
"{}#{id}: the translated state diverges from the reference transform at {}",
|
|
962
|
+
aggregate.name,
|
|
963
|
+
diverged.join(", ")
|
|
964
|
+
));
|
|
776
965
|
}
|
|
777
966
|
}
|
|
778
967
|
Ok(())
|
|
779
968
|
}
|
|
780
969
|
|
|
781
|
-
fn strip_keys(state: &Value, keys: &std::collections::HashSet<String>) -> Value {
|
|
782
|
-
match state {
|
|
783
|
-
Value::Object(map) => Value::Object(map.iter().filter(|(k, _)| !keys.contains(*k)).map(|(k, v)| (k.clone(), v.clone())).collect()),
|
|
784
|
-
other => other.clone(),
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
|
|
788
970
|
/// The whole mint-time audit, over every lineage-capable aggregate. Runs
|
|
789
971
|
/// before anything is minted, so a refusal leaves no half-born era.
|
|
790
972
|
///
|
|
791
973
|
/// `ir` is the whole `ir.json` value; Layer 1 looks up each aggregate's node from it.
|
|
974
|
+
#[allow(clippy::implicit_hasher)] // reason: callers build the map with the default hasher; no caller needs another
|
|
975
|
+
#[allow(clippy::too_many_arguments)]
|
|
792
976
|
pub async fn audit_before_mint<C: GenericClient>(
|
|
793
977
|
client: &C,
|
|
794
978
|
domain: &str,
|
|
@@ -802,13 +986,21 @@ pub async fn audit_before_mint<C: GenericClient>(
|
|
|
802
986
|
let ir_aggregates: std::collections::HashMap<&str, &Value> = ir
|
|
803
987
|
.get("aggregates")
|
|
804
988
|
.and_then(Value::as_array)
|
|
805
|
-
.map(|list|
|
|
989
|
+
.map(|list| {
|
|
990
|
+
list.iter()
|
|
991
|
+
.filter_map(|agg| agg.get("name").and_then(Value::as_str).map(|n| (n, agg)))
|
|
992
|
+
.collect()
|
|
993
|
+
})
|
|
806
994
|
.unwrap_or_default();
|
|
807
995
|
|
|
808
996
|
let mut violations = Vec::new();
|
|
809
997
|
for aggregate in aggregates {
|
|
810
|
-
let phase = crate::log::phase_with(
|
|
811
|
-
|
|
998
|
+
let phase = crate::log::phase_with(
|
|
999
|
+
"audit_aggregate",
|
|
1000
|
+
serde_json::json!({ "aggregate": &aggregate.storage_name }),
|
|
1001
|
+
);
|
|
1002
|
+
let after =
|
|
1003
|
+
translated_latest(client, domain, &aggregate.name, ordinal, edges, watermarks).await?;
|
|
812
1004
|
|
|
813
1005
|
// Layer 1 — structural (types/patterns/admits/lifecycle) — over
|
|
814
1006
|
// every record `after` actually holds, matching Ruby's own
|
|
@@ -821,13 +1013,27 @@ pub async fn audit_before_mint<C: GenericClient>(
|
|
|
821
1013
|
|
|
822
1014
|
// Layer 2 — per-rule value preservation against the reference
|
|
823
1015
|
// transform, id-set/count conservation.
|
|
824
|
-
audit_layer_two(
|
|
1016
|
+
audit_layer_two(
|
|
1017
|
+
client,
|
|
1018
|
+
domain,
|
|
1019
|
+
aggregate,
|
|
1020
|
+
ordinal,
|
|
1021
|
+
edges,
|
|
1022
|
+
raw_edges,
|
|
1023
|
+
watermarks,
|
|
1024
|
+
&after,
|
|
1025
|
+
&mut violations,
|
|
1026
|
+
)
|
|
1027
|
+
.await?;
|
|
825
1028
|
phase.end_with(serde_json::json!({ "records": after.len() }));
|
|
826
1029
|
}
|
|
827
1030
|
if violations.is_empty() {
|
|
828
1031
|
return Ok(());
|
|
829
1032
|
}
|
|
830
|
-
anyhow::bail!(
|
|
1033
|
+
anyhow::bail!(
|
|
1034
|
+
"cannot mint era {ordinal} of {domain}: the audit refused —\n - {}",
|
|
1035
|
+
violations.join("\n - ")
|
|
1036
|
+
);
|
|
831
1037
|
}
|
|
832
1038
|
|
|
833
1039
|
/// `compile_head!` — one aggregate's matview + head-snapshot + head
|
|
@@ -845,7 +1051,12 @@ async fn compile_head<C: GenericClient>(
|
|
|
845
1051
|
let view = matview(domain, storage_name, era, label);
|
|
846
1052
|
let body = chain_sql(domain, &aggregate.name, era, edges, watermarks);
|
|
847
1053
|
|
|
848
|
-
client
|
|
1054
|
+
client
|
|
1055
|
+
.batch_execute(&format!(
|
|
1056
|
+
"CREATE MATERIALIZED VIEW {} AS\n{body}",
|
|
1057
|
+
quote_ident(&view)
|
|
1058
|
+
))
|
|
1059
|
+
.await?;
|
|
849
1060
|
client
|
|
850
1061
|
.batch_execute(&format!(
|
|
851
1062
|
"CREATE INDEX IF NOT EXISTS {} ON {} (aggregate_id, ordinal DESC)",
|
|
@@ -856,7 +1067,12 @@ async fn compile_head<C: GenericClient>(
|
|
|
856
1067
|
|
|
857
1068
|
ensure_head_snapshot(client, domain, storage_name, era).await?;
|
|
858
1069
|
|
|
859
|
-
client
|
|
1070
|
+
client
|
|
1071
|
+
.batch_execute(&format!(
|
|
1072
|
+
"DROP VIEW IF EXISTS {}",
|
|
1073
|
+
quote_ident(&head_view(domain, storage_name))
|
|
1074
|
+
))
|
|
1075
|
+
.await?;
|
|
860
1076
|
client
|
|
861
1077
|
.batch_execute(&format!(
|
|
862
1078
|
"CREATE VIEW {} AS \
|
|
@@ -874,8 +1090,15 @@ async fn compile_head<C: GenericClient>(
|
|
|
874
1090
|
.await?;
|
|
875
1091
|
Ok(())
|
|
876
1092
|
}
|
|
877
|
-
/// Mints era 1: fresh domain, no edge
|
|
878
|
-
pub async fn hold_first<C: GenericClient>(
|
|
1093
|
+
/// Mints era 1: fresh domain, no edge to translate.
|
|
1094
|
+
pub async fn hold_first<C: GenericClient>(
|
|
1095
|
+
client: &C,
|
|
1096
|
+
domain: &str,
|
|
1097
|
+
held_text: &str,
|
|
1098
|
+
ir: &Value,
|
|
1099
|
+
aggregates: &[Aggregate],
|
|
1100
|
+
role: Option<&str>,
|
|
1101
|
+
) -> anyhow::Result<()> {
|
|
879
1102
|
ensure_base(client, domain).await?;
|
|
880
1103
|
|
|
881
1104
|
// Own transaction: `ensure_first_head`'s `ensure_head_snapshot` backfill
|
|
@@ -883,7 +1106,7 @@ pub async fn hold_first<C: GenericClient>(client: &C, domain: &str, held_text: &
|
|
|
883
1106
|
client.batch_execute("BEGIN").await?;
|
|
884
1107
|
let result = hold_first_body(client, domain, held_text, ir, aggregates, role).await;
|
|
885
1108
|
match &result {
|
|
886
|
-
Ok(
|
|
1109
|
+
Ok(()) => client.batch_execute("COMMIT").await?,
|
|
887
1110
|
Err(_) => {
|
|
888
1111
|
let _ = client.batch_execute("ROLLBACK").await;
|
|
889
1112
|
}
|
|
@@ -891,7 +1114,14 @@ pub async fn hold_first<C: GenericClient>(client: &C, domain: &str, held_text: &
|
|
|
891
1114
|
result
|
|
892
1115
|
}
|
|
893
1116
|
|
|
894
|
-
async fn hold_first_body<C: GenericClient>(
|
|
1117
|
+
async fn hold_first_body<C: GenericClient>(
|
|
1118
|
+
client: &C,
|
|
1119
|
+
domain: &str,
|
|
1120
|
+
held_text: &str,
|
|
1121
|
+
ir: &Value,
|
|
1122
|
+
aggregates: &[Aggregate],
|
|
1123
|
+
role: Option<&str>,
|
|
1124
|
+
) -> anyhow::Result<()> {
|
|
895
1125
|
let hash = storage_shape::mint_hash(ir);
|
|
896
1126
|
let label = storage_shape::mint_label(ir);
|
|
897
1127
|
let digest = format!("{:x}", Sha256::digest(held_text.as_bytes()));
|
|
@@ -917,7 +1147,11 @@ async fn hold_first_body<C: GenericClient>(client: &C, domain: &str, held_text:
|
|
|
917
1147
|
/// `ensure_first_head!` — era 1's head is a plain view over its own
|
|
918
1148
|
/// (immediately-created) snapshot table, no chain to compile, no
|
|
919
1149
|
/// translation possible from nothing.
|
|
920
|
-
async fn ensure_first_head<C: GenericClient>(
|
|
1150
|
+
async fn ensure_first_head<C: GenericClient>(
|
|
1151
|
+
client: &C,
|
|
1152
|
+
domain: &str,
|
|
1153
|
+
storage_name: &str,
|
|
1154
|
+
) -> anyhow::Result<()> {
|
|
921
1155
|
ensure_head_snapshot(client, domain, storage_name, 1).await?;
|
|
922
1156
|
client
|
|
923
1157
|
.batch_execute(&format!(
|
|
@@ -929,7 +1163,13 @@ async fn ensure_first_head<C: GenericClient>(client: &C, domain: &str, storage_n
|
|
|
929
1163
|
Ok(())
|
|
930
1164
|
}
|
|
931
1165
|
|
|
932
|
-
async fn archive_text<C: GenericClient>(
|
|
1166
|
+
async fn archive_text<C: GenericClient>(
|
|
1167
|
+
client: &C,
|
|
1168
|
+
domain: &str,
|
|
1169
|
+
ordinal: i32,
|
|
1170
|
+
text: &str,
|
|
1171
|
+
digest: &str,
|
|
1172
|
+
) -> anyhow::Result<()> {
|
|
933
1173
|
client
|
|
934
1174
|
.execute(
|
|
935
1175
|
"INSERT INTO hecks_era_texts (domain, ordinal, digest, held_text) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
|
|
@@ -939,25 +1179,57 @@ async fn archive_text<C: GenericClient>(client: &C, domain: &str, ordinal: i32,
|
|
|
939
1179
|
Ok(())
|
|
940
1180
|
}
|
|
941
1181
|
|
|
942
|
-
pub(crate) async fn advance_era<C: GenericClient>(
|
|
1182
|
+
pub(crate) async fn advance_era<C: GenericClient>(
|
|
1183
|
+
client: &C,
|
|
1184
|
+
domain: &str,
|
|
1185
|
+
ordinal: i32,
|
|
1186
|
+
) -> anyhow::Result<()> {
|
|
943
1187
|
let journal = quote_ident(&journal_table(domain));
|
|
944
|
-
client
|
|
1188
|
+
client
|
|
1189
|
+
.batch_execute(&format!(
|
|
1190
|
+
"DROP POLICY IF EXISTS hecks_current_era ON {journal}"
|
|
1191
|
+
))
|
|
1192
|
+
.await?;
|
|
945
1193
|
client
|
|
946
1194
|
.batch_execute(&format!("CREATE POLICY hecks_current_era ON {journal} FOR INSERT TO PUBLIC WITH CHECK (era = {ordinal})"))
|
|
947
1195
|
.await?;
|
|
948
|
-
client
|
|
949
|
-
|
|
1196
|
+
client
|
|
1197
|
+
.batch_execute(&format!(
|
|
1198
|
+
"DROP POLICY IF EXISTS hecks_read_all ON {journal}"
|
|
1199
|
+
))
|
|
1200
|
+
.await?;
|
|
1201
|
+
client
|
|
1202
|
+
.batch_execute(&format!(
|
|
1203
|
+
"CREATE POLICY hecks_read_all ON {journal} FOR SELECT TO PUBLIC USING (true)"
|
|
1204
|
+
))
|
|
1205
|
+
.await?;
|
|
950
1206
|
Ok(())
|
|
951
1207
|
}
|
|
952
1208
|
|
|
953
|
-
async fn grant_role<C: GenericClient>(
|
|
1209
|
+
async fn grant_role<C: GenericClient>(
|
|
1210
|
+
client: &C,
|
|
1211
|
+
domain: &str,
|
|
1212
|
+
role: &str,
|
|
1213
|
+
aggregates: &[Aggregate],
|
|
1214
|
+
era: Option<i32>,
|
|
1215
|
+
) -> anyhow::Result<()> {
|
|
954
1216
|
let journal = journal_table(domain);
|
|
955
1217
|
if !provisioner(client, &journal).await? {
|
|
956
1218
|
return Ok(());
|
|
957
1219
|
}
|
|
958
1220
|
let quoted_role = quote_ident(role);
|
|
959
|
-
client
|
|
960
|
-
|
|
1221
|
+
client
|
|
1222
|
+
.batch_execute(&format!(
|
|
1223
|
+
"GRANT INSERT, SELECT ON {} TO {quoted_role}",
|
|
1224
|
+
quote_ident(&journal)
|
|
1225
|
+
))
|
|
1226
|
+
.await?;
|
|
1227
|
+
client
|
|
1228
|
+
.batch_execute(&format!(
|
|
1229
|
+
"GRANT USAGE ON SEQUENCE {} TO {quoted_role}",
|
|
1230
|
+
quote_ident(&sequence(domain))
|
|
1231
|
+
))
|
|
1232
|
+
.await?;
|
|
961
1233
|
let Some(era) = era else { return Ok(()) };
|
|
962
1234
|
for aggregate in aggregates {
|
|
963
1235
|
let storage_name = &aggregate.storage_name;
|
|
@@ -969,7 +1241,12 @@ async fn grant_role<C: GenericClient>(client: &C, domain: &str, role: &str, aggr
|
|
|
969
1241
|
.await?;
|
|
970
1242
|
let view = head_view(domain, storage_name);
|
|
971
1243
|
if table_or_view_exists(client, &view).await? {
|
|
972
|
-
client
|
|
1244
|
+
client
|
|
1245
|
+
.batch_execute(&format!(
|
|
1246
|
+
"GRANT SELECT ON {} TO {quoted_role}",
|
|
1247
|
+
quote_ident(&view)
|
|
1248
|
+
))
|
|
1249
|
+
.await?;
|
|
973
1250
|
}
|
|
974
1251
|
}
|
|
975
1252
|
Ok(())
|
|
@@ -982,8 +1259,8 @@ async fn table_or_view_exists<C: GenericClient>(client: &C, name: &str) -> anyho
|
|
|
982
1259
|
Ok(row.is_some())
|
|
983
1260
|
}
|
|
984
1261
|
|
|
985
|
-
/// The one transaction that makes era `ordinal` real
|
|
986
|
-
///
|
|
1262
|
+
/// The one transaction that makes era `ordinal` real; the caller decides when
|
|
1263
|
+
/// to mint, and this function mints once asked to.
|
|
987
1264
|
#[allow(clippy::too_many_arguments)]
|
|
988
1265
|
pub async fn mint_era<C: GenericClient>(
|
|
989
1266
|
client: &C,
|
|
@@ -998,10 +1275,23 @@ pub async fn mint_era<C: GenericClient>(
|
|
|
998
1275
|
lifecycle_defaults: &[LifecycleDefault],
|
|
999
1276
|
) -> anyhow::Result<()> {
|
|
1000
1277
|
client.batch_execute("BEGIN").await?;
|
|
1001
|
-
let result = mint_era_body(
|
|
1278
|
+
let result = mint_era_body(
|
|
1279
|
+
client,
|
|
1280
|
+
domain,
|
|
1281
|
+
ordinal,
|
|
1282
|
+
hash,
|
|
1283
|
+
label,
|
|
1284
|
+
held_text,
|
|
1285
|
+
aggregates,
|
|
1286
|
+
edges,
|
|
1287
|
+
role,
|
|
1288
|
+
lifecycle_defaults,
|
|
1289
|
+
)
|
|
1290
|
+
.await;
|
|
1002
1291
|
match &result {
|
|
1003
|
-
Ok(
|
|
1004
|
-
let phase =
|
|
1292
|
+
Ok(()) => {
|
|
1293
|
+
let phase =
|
|
1294
|
+
crate::log::phase_with("mint_commit", serde_json::json!({ "era": ordinal }));
|
|
1005
1295
|
client.batch_execute("COMMIT").await?;
|
|
1006
1296
|
phase.end();
|
|
1007
1297
|
}
|
|
@@ -1011,7 +1301,6 @@ pub async fn mint_era<C: GenericClient>(
|
|
|
1011
1301
|
}
|
|
1012
1302
|
result
|
|
1013
1303
|
}
|
|
1014
|
-
|
|
1015
1304
|
#[allow(clippy::too_many_arguments)]
|
|
1016
1305
|
async fn mint_era_body<C: GenericClient>(
|
|
1017
1306
|
client: &C,
|
|
@@ -1025,12 +1314,25 @@ async fn mint_era_body<C: GenericClient>(
|
|
|
1025
1314
|
role: Option<&str>,
|
|
1026
1315
|
lifecycle_defaults: &[LifecycleDefault],
|
|
1027
1316
|
) -> anyhow::Result<()> {
|
|
1028
|
-
client.batch_execute("SET LOCAL lock_timeout = '10s'").await?;
|
|
1029
1317
|
client
|
|
1030
|
-
.
|
|
1318
|
+
.batch_execute("SET LOCAL lock_timeout = '10s'")
|
|
1319
|
+
.await?;
|
|
1320
|
+
client
|
|
1321
|
+
.execute(
|
|
1322
|
+
&format!(
|
|
1323
|
+
"SELECT pg_advisory_xact_lock(hashtext('hecks_eras:' || {}))",
|
|
1324
|
+
text_literal(domain)
|
|
1325
|
+
),
|
|
1326
|
+
&[],
|
|
1327
|
+
)
|
|
1031
1328
|
.await?;
|
|
1032
1329
|
|
|
1033
|
-
let already_minted = client
|
|
1330
|
+
let already_minted = client
|
|
1331
|
+
.query_opt(
|
|
1332
|
+
"SELECT 1 FROM hecks_eras WHERE domain = $1 AND ordinal = $2",
|
|
1333
|
+
&[&domain, &ordinal],
|
|
1334
|
+
)
|
|
1335
|
+
.await?;
|
|
1034
1336
|
if already_minted.is_some() {
|
|
1035
1337
|
anyhow::bail!("era {ordinal} of {domain} is already minted — nothing to do");
|
|
1036
1338
|
}
|
|
@@ -1050,11 +1352,26 @@ async fn mint_era_body<C: GenericClient>(
|
|
|
1050
1352
|
// Every held era's watermark, for `ancestor_tail_sql`'s cut — includes
|
|
1051
1353
|
// the row just inserted, which bounds era `ordinal - 1`'s tail read.
|
|
1052
1354
|
let held = crate::journal::held_eras(client, domain).await?;
|
|
1053
|
-
let watermarks: std::collections::HashMap<i32, Option<i64>> = held
|
|
1355
|
+
let watermarks: std::collections::HashMap<i32, Option<i64>> = held
|
|
1356
|
+
.iter()
|
|
1357
|
+
.map(|era| (era.ordinal, era.watermark))
|
|
1358
|
+
.collect();
|
|
1054
1359
|
|
|
1055
1360
|
for aggregate in aggregates {
|
|
1056
|
-
let phase = crate::log::phase_with(
|
|
1057
|
-
|
|
1361
|
+
let phase = crate::log::phase_with(
|
|
1362
|
+
"compile_head",
|
|
1363
|
+
serde_json::json!({ "aggregate": &aggregate.storage_name, "era": ordinal }),
|
|
1364
|
+
);
|
|
1365
|
+
compile_head(
|
|
1366
|
+
client,
|
|
1367
|
+
domain,
|
|
1368
|
+
aggregate,
|
|
1369
|
+
ordinal,
|
|
1370
|
+
label,
|
|
1371
|
+
edges,
|
|
1372
|
+
&watermarks,
|
|
1373
|
+
)
|
|
1374
|
+
.await?;
|
|
1058
1375
|
phase.end();
|
|
1059
1376
|
}
|
|
1060
1377
|
|
|
@@ -1073,7 +1390,7 @@ async fn mint_era_body<C: GenericClient>(
|
|
|
1073
1390
|
|
|
1074
1391
|
/// A lifecycle field an aggregate's instances must carry, and its default
|
|
1075
1392
|
/// for new instances. `key_prefix` matches the kernel's `instances()` dump key shape.
|
|
1076
|
-
#[derive(Debug, Clone, PartialEq)]
|
|
1393
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
1077
1394
|
pub struct LifecycleDefault {
|
|
1078
1395
|
pub key_prefix: String,
|
|
1079
1396
|
pub field: String,
|
|
@@ -1108,13 +1425,22 @@ pub fn lifecycle_defaults(ir: &Value) -> Vec<LifecycleDefault> {
|
|
|
1108
1425
|
/// Fills a missing lifecycle field into every seeded instance; returns how
|
|
1109
1426
|
/// many changed. Idempotent, since the kernel's `from_json` requires the field on load.
|
|
1110
1427
|
pub fn fill_lifecycle_defaults(seed: &mut Value, defaults: &[LifecycleDefault]) -> usize {
|
|
1111
|
-
let Some(instances) = seed.as_object_mut() else {
|
|
1428
|
+
let Some(instances) = seed.as_object_mut() else {
|
|
1429
|
+
return 0;
|
|
1430
|
+
};
|
|
1112
1431
|
let mut changed = 0;
|
|
1113
1432
|
for (key, instance) in instances.iter_mut() {
|
|
1114
|
-
let Some(default) = defaults.iter().find(|d| key.starts_with(&d.key_prefix)) else {
|
|
1115
|
-
|
|
1433
|
+
let Some(default) = defaults.iter().find(|d| key.starts_with(&d.key_prefix)) else {
|
|
1434
|
+
continue;
|
|
1435
|
+
};
|
|
1436
|
+
let Some(fields) = instance.as_object_mut() else {
|
|
1437
|
+
continue;
|
|
1438
|
+
};
|
|
1116
1439
|
if fields.get(&default.field).is_none_or(Value::is_null) {
|
|
1117
|
-
fields.insert(
|
|
1440
|
+
fields.insert(
|
|
1441
|
+
default.field.clone(),
|
|
1442
|
+
Value::String(default.default.clone()),
|
|
1443
|
+
);
|
|
1118
1444
|
changed += 1;
|
|
1119
1445
|
}
|
|
1120
1446
|
}
|
|
@@ -1125,38 +1451,69 @@ pub fn fill_lifecycle_defaults(seed: &mut Value, defaults: &[LifecycleDefault])
|
|
|
1125
1451
|
/// transaction — without this, a request right after the mint can hit
|
|
1126
1452
|
/// `invalid seed` on the old-shaped snapshot. Takes the same advisory lock a
|
|
1127
1453
|
/// dispatch holds, so an in-flight dispatch saves first.
|
|
1128
|
-
async fn fill_snapshot_lifecycle_defaults<C: GenericClient>(
|
|
1454
|
+
async fn fill_snapshot_lifecycle_defaults<C: GenericClient>(
|
|
1455
|
+
client: &C,
|
|
1456
|
+
domain: &str,
|
|
1457
|
+
defaults: &[LifecycleDefault],
|
|
1458
|
+
) -> anyhow::Result<usize> {
|
|
1129
1459
|
if defaults.is_empty() {
|
|
1130
1460
|
return Ok(0);
|
|
1131
1461
|
}
|
|
1132
|
-
let table_exists: bool = client
|
|
1462
|
+
let table_exists: bool = client
|
|
1463
|
+
.query_one(
|
|
1464
|
+
"SELECT to_regclass('hecks_lambda_snapshot') IS NOT NULL",
|
|
1465
|
+
&[],
|
|
1466
|
+
)
|
|
1467
|
+
.await?
|
|
1468
|
+
.get(0);
|
|
1133
1469
|
if !table_exists {
|
|
1134
1470
|
return Ok(0);
|
|
1135
1471
|
}
|
|
1136
|
-
client
|
|
1137
|
-
|
|
1472
|
+
client
|
|
1473
|
+
.execute(
|
|
1474
|
+
"SELECT pg_advisory_xact_lock(hashtext('hecks_lambda_journal.' || $1::text))",
|
|
1475
|
+
&[&domain],
|
|
1476
|
+
)
|
|
1477
|
+
.await?;
|
|
1478
|
+
let Some(row) = client
|
|
1479
|
+
.query_opt("SELECT seed FROM hecks_lambda_snapshot FOR UPDATE", &[])
|
|
1480
|
+
.await?
|
|
1481
|
+
else {
|
|
1482
|
+
return Ok(0);
|
|
1483
|
+
};
|
|
1138
1484
|
let mut seed: Value = row.get(0);
|
|
1139
1485
|
let changed = fill_lifecycle_defaults(&mut seed, defaults);
|
|
1140
1486
|
if changed > 0 {
|
|
1141
|
-
client
|
|
1487
|
+
client
|
|
1488
|
+
.execute("UPDATE hecks_lambda_snapshot SET seed = $1", &[&seed])
|
|
1489
|
+
.await
|
|
1490
|
+
.context("filling lifecycle defaults into the snapshot")?;
|
|
1142
1491
|
}
|
|
1143
1492
|
Ok(changed)
|
|
1144
1493
|
}
|
|
1145
1494
|
|
|
1146
1495
|
#[cfg(test)]
|
|
1496
|
+
#[allow(clippy::items_after_statements)] // reason: tests declare a one-off fixture next to the statement that uses it
|
|
1147
1497
|
mod tests {
|
|
1148
1498
|
|
|
1149
1499
|
/// A throwaway database of its own — `mint.rs` also compiles into
|
|
1150
1500
|
/// `bin/mint_harness`, which has no `dispatch` module to borrow a helper from.
|
|
1151
1501
|
async fn own_scratch_db(name: &str) -> tokio_postgres::Client {
|
|
1152
1502
|
let (admin, connection) =
|
|
1153
|
-
tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls)
|
|
1503
|
+
tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls)
|
|
1504
|
+
.await
|
|
1505
|
+
.expect("connect as admin");
|
|
1154
1506
|
tokio::spawn(async move {
|
|
1155
1507
|
let _ = connection.await;
|
|
1156
1508
|
});
|
|
1157
|
-
let _ = admin
|
|
1158
|
-
|
|
1159
|
-
|
|
1509
|
+
let _ = admin
|
|
1510
|
+
.batch_execute(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)"))
|
|
1511
|
+
.await;
|
|
1512
|
+
admin
|
|
1513
|
+
.batch_execute(&format!("CREATE DATABASE {name}"))
|
|
1514
|
+
.await
|
|
1515
|
+
.expect("create scratch db");
|
|
1516
|
+
let (client, connection) = tokio_postgres::connect(&crate::test_pg::conninfo(name), NoTls)
|
|
1160
1517
|
.await
|
|
1161
1518
|
.expect("connect to scratch db");
|
|
1162
1519
|
tokio::spawn(async move {
|
|
@@ -1175,23 +1532,42 @@ mod tests {
|
|
|
1175
1532
|
super::adopt_head_snapshots(
|
|
1176
1533
|
&guard,
|
|
1177
1534
|
"SampleApp",
|
|
1178
|
-
&[super::Aggregate {
|
|
1535
|
+
&[super::Aggregate {
|
|
1536
|
+
name: "StateStyle".to_string(),
|
|
1537
|
+
storage_name: "state_style".to_string(),
|
|
1538
|
+
}],
|
|
1179
1539
|
1,
|
|
1180
1540
|
)
|
|
1181
1541
|
.await
|
|
1182
1542
|
.expect("era 1 exists, as Ruby left it");
|
|
1183
1543
|
|
|
1184
1544
|
let missing = super::head_snapshot("SampleApp", "state_style", 2);
|
|
1185
|
-
assert!(
|
|
1545
|
+
assert!(
|
|
1546
|
+
!super::table_exists(&guard, &missing)
|
|
1547
|
+
.await
|
|
1548
|
+
.expect("a lookup"),
|
|
1549
|
+
"era 2 starts absent"
|
|
1550
|
+
);
|
|
1186
1551
|
|
|
1187
|
-
let aggregates =
|
|
1188
|
-
|
|
1189
|
-
|
|
1552
|
+
let aggregates = vec![super::Aggregate {
|
|
1553
|
+
name: "StateStyle".to_string(),
|
|
1554
|
+
storage_name: "state_style".to_string(),
|
|
1555
|
+
}];
|
|
1556
|
+
super::adopt_head_snapshots(&guard, "SampleApp", &aggregates, 2)
|
|
1557
|
+
.await
|
|
1558
|
+
.expect("adopts");
|
|
1190
1559
|
|
|
1191
|
-
assert!(
|
|
1560
|
+
assert!(
|
|
1561
|
+
super::table_exists(&guard, &missing)
|
|
1562
|
+
.await
|
|
1563
|
+
.expect("a lookup"),
|
|
1564
|
+
"era 2 is provisioned now"
|
|
1565
|
+
);
|
|
1192
1566
|
|
|
1193
1567
|
// Idempotent — every boot runs it, the same way Ruby's does.
|
|
1194
|
-
super::adopt_head_snapshots(&guard, "SampleApp", &aggregates, 2)
|
|
1568
|
+
super::adopt_head_snapshots(&guard, "SampleApp", &aggregates, 2)
|
|
1569
|
+
.await
|
|
1570
|
+
.expect("adopts again");
|
|
1195
1571
|
}
|
|
1196
1572
|
|
|
1197
1573
|
/// The columns match Ruby's, so a table this crate creates is one Ruby can
|
|
@@ -1199,8 +1575,13 @@ mod tests {
|
|
|
1199
1575
|
#[tokio::test]
|
|
1200
1576
|
async fn a_head_snapshot_this_crate_creates_carries_rubys_own_columns() {
|
|
1201
1577
|
let guard = own_scratch_db("hecks_host_head_snapshot_columns").await;
|
|
1202
|
-
let aggregates = vec![super::Aggregate {
|
|
1203
|
-
|
|
1578
|
+
let aggregates = vec![super::Aggregate {
|
|
1579
|
+
name: "Widget".to_string(),
|
|
1580
|
+
storage_name: "widget".to_string(),
|
|
1581
|
+
}];
|
|
1582
|
+
super::adopt_head_snapshots(&guard, "Fixtures", &aggregates, 1)
|
|
1583
|
+
.await
|
|
1584
|
+
.expect("adopts");
|
|
1204
1585
|
|
|
1205
1586
|
let name = super::head_snapshot("Fixtures", "widget", 1);
|
|
1206
1587
|
let rows = guard
|
|
@@ -1214,19 +1595,40 @@ mod tests {
|
|
|
1214
1595
|
let columns: Vec<String> = rows.iter().map(|r| r.get::<_, String>(0)).collect();
|
|
1215
1596
|
assert_eq!(columns, vec!["id", "ordinal", "operation", "state"]);
|
|
1216
1597
|
|
|
1217
|
-
let operation = rows
|
|
1598
|
+
let operation = rows
|
|
1599
|
+
.iter()
|
|
1600
|
+
.find(|r| r.get::<_, String>(0) == "operation")
|
|
1601
|
+
.expect("operation");
|
|
1218
1602
|
assert_eq!(operation.get::<_, String>(1), "NO", "operation is NOT NULL");
|
|
1219
|
-
assert!(
|
|
1603
|
+
assert!(
|
|
1604
|
+
operation
|
|
1605
|
+
.get::<_, Option<String>>(2)
|
|
1606
|
+
.unwrap_or_default()
|
|
1607
|
+
.contains("save"),
|
|
1608
|
+
"defaults to 'save'"
|
|
1609
|
+
);
|
|
1220
1610
|
|
|
1221
|
-
let state = rows
|
|
1222
|
-
|
|
1611
|
+
let state = rows
|
|
1612
|
+
.iter()
|
|
1613
|
+
.find(|r| r.get::<_, String>(0) == "state")
|
|
1614
|
+
.expect("state");
|
|
1615
|
+
assert_eq!(
|
|
1616
|
+
state.get::<_, String>(1),
|
|
1617
|
+
"YES",
|
|
1618
|
+
"state is nullable, for a delete tombstone"
|
|
1619
|
+
);
|
|
1223
1620
|
}
|
|
1224
1621
|
use super::*;
|
|
1225
1622
|
use crate::journal;
|
|
1226
1623
|
use tokio_postgres::NoTls;
|
|
1227
1624
|
|
|
1228
1625
|
fn rule(name: &str, was: Option<&str>, expression: &str) -> EdgeAggregate {
|
|
1229
|
-
EdgeAggregate {
|
|
1626
|
+
EdgeAggregate {
|
|
1627
|
+
name: name.to_string(),
|
|
1628
|
+
was: was.map(str::to_string),
|
|
1629
|
+
compiled_state_expression: expression.to_string(),
|
|
1630
|
+
compiled_id_expression: None,
|
|
1631
|
+
}
|
|
1230
1632
|
}
|
|
1231
1633
|
|
|
1232
1634
|
// A multi-hop chain: era1 "Pizza" -> era2 renamed to "Order" -> era3 an
|
|
@@ -1234,8 +1636,20 @@ mod tests {
|
|
|
1234
1636
|
// correctly for chain_sql's per-ancestor filter to read the right era.
|
|
1235
1637
|
#[test]
|
|
1236
1638
|
fn names_by_era_walks_a_multi_hop_rename_chain_backward_correctly() {
|
|
1237
|
-
let edge_1_to_2 = Edge {
|
|
1238
|
-
|
|
1639
|
+
let edge_1_to_2 = Edge {
|
|
1640
|
+
from: "aaa".to_string(),
|
|
1641
|
+
to: "bbb".to_string(),
|
|
1642
|
+
aggregates: vec![rule(
|
|
1643
|
+
"Order",
|
|
1644
|
+
Some("Pizza"),
|
|
1645
|
+
"hecks_tr_rename(state, 'x', 'y')",
|
|
1646
|
+
)],
|
|
1647
|
+
};
|
|
1648
|
+
let edge_2_to_3 = Edge {
|
|
1649
|
+
from: "bbb".to_string(),
|
|
1650
|
+
to: "ccc".to_string(),
|
|
1651
|
+
aggregates: vec![rule("Order", None, "hecks_tr_rename(state, 'y', 'z')")],
|
|
1652
|
+
};
|
|
1239
1653
|
let edges: Vec<&Edge> = vec![&edge_1_to_2, &edge_2_to_3];
|
|
1240
1654
|
|
|
1241
1655
|
let (current, storage) = names_by_era("Order", &edges);
|
|
@@ -1243,25 +1657,53 @@ mod tests {
|
|
|
1243
1657
|
// era 1 (index 0): the old name, "Pizza" -- read from edge_1_to_2's
|
|
1244
1658
|
// own `was:`. era 2 (index 1) and era 3 (index 2): "Order",
|
|
1245
1659
|
// unchanged since nothing renamed it again.
|
|
1246
|
-
assert_eq!(
|
|
1247
|
-
|
|
1660
|
+
assert_eq!(
|
|
1661
|
+
current,
|
|
1662
|
+
vec![
|
|
1663
|
+
"Pizza".to_string(),
|
|
1664
|
+
"Order".to_string(),
|
|
1665
|
+
"Order".to_string()
|
|
1666
|
+
]
|
|
1667
|
+
);
|
|
1668
|
+
assert_eq!(
|
|
1669
|
+
storage,
|
|
1670
|
+
vec![
|
|
1671
|
+
"pizza".to_string(),
|
|
1672
|
+
"order".to_string(),
|
|
1673
|
+
"order".to_string()
|
|
1674
|
+
]
|
|
1675
|
+
);
|
|
1248
1676
|
}
|
|
1249
1677
|
|
|
1250
1678
|
#[test]
|
|
1251
1679
|
fn edge_chain_finds_the_real_path_and_refuses_a_broken_one() {
|
|
1252
|
-
let edge = Edge {
|
|
1253
|
-
|
|
1680
|
+
let edge = Edge {
|
|
1681
|
+
from: "aaa".to_string(),
|
|
1682
|
+
to: "bbb".to_string(),
|
|
1683
|
+
aggregates: vec![],
|
|
1684
|
+
};
|
|
1685
|
+
let edges = vec![edge];
|
|
1254
1686
|
|
|
1255
|
-
let found = edge_chain(&edges, &["aaa".to_string(), "bbb".to_string()])
|
|
1687
|
+
let found = edge_chain(&edges, &["aaa".to_string(), "bbb".to_string()])
|
|
1688
|
+
.expect("a real edge connects aaa to bbb");
|
|
1256
1689
|
assert_eq!(found.len(), 1);
|
|
1257
1690
|
assert_eq!(found[0].to, "bbb");
|
|
1258
1691
|
|
|
1259
1692
|
let broken = edge_chain(&edges, &["aaa".to_string(), "zzz".to_string()]);
|
|
1260
|
-
assert!(
|
|
1693
|
+
assert!(
|
|
1694
|
+
broken.is_err(),
|
|
1695
|
+
"no edge leads aaa to zzz -- must refuse, not silently skip"
|
|
1696
|
+
);
|
|
1261
1697
|
}
|
|
1262
1698
|
|
|
1263
1699
|
fn held(ordinal: i32, label: Option<&str>) -> journal::HeldEra {
|
|
1264
|
-
journal::HeldEra {
|
|
1700
|
+
journal::HeldEra {
|
|
1701
|
+
ordinal,
|
|
1702
|
+
hash: label.map(|_| "irrelevant".to_string()),
|
|
1703
|
+
label: label.map(str::to_string),
|
|
1704
|
+
held_text: String::new(),
|
|
1705
|
+
watermark: None,
|
|
1706
|
+
}
|
|
1265
1707
|
}
|
|
1266
1708
|
|
|
1267
1709
|
// The four branches `main.rs`'s boot gate calls into — pure, so each
|
|
@@ -1275,21 +1717,34 @@ mod tests {
|
|
|
1275
1717
|
// even if it's not the latest one (an operator could roll back
|
|
1276
1718
|
// to an older binary whose shape a prior era already covers).
|
|
1277
1719
|
let already_named = vec![held(1, Some("aaa111")), held(2, Some("bbb222"))];
|
|
1278
|
-
assert_eq!(
|
|
1279
|
-
|
|
1720
|
+
assert_eq!(
|
|
1721
|
+
decide_boot_action(&already_named, "aaa111"),
|
|
1722
|
+
BootDecision::UseExisting { ordinal: 1 }
|
|
1723
|
+
);
|
|
1724
|
+
assert_eq!(
|
|
1725
|
+
decide_boot_action(&already_named, "bbb222"),
|
|
1726
|
+
BootDecision::UseExisting { ordinal: 2 }
|
|
1727
|
+
);
|
|
1280
1728
|
|
|
1281
1729
|
// Held, but the latest doesn't match this binary's shape -- mint
|
|
1282
1730
|
// the next ordinal from the latest's own label.
|
|
1283
1731
|
let drifted = vec![held(1, Some("aaa111"))];
|
|
1284
1732
|
assert_eq!(
|
|
1285
1733
|
decide_boot_action(&drifted, "ccc333"),
|
|
1286
|
-
BootDecision::Mint {
|
|
1734
|
+
BootDecision::Mint {
|
|
1735
|
+
ordinal: 2,
|
|
1736
|
+
from_ordinal: 1,
|
|
1737
|
+
from_label: "aaa111".to_string()
|
|
1738
|
+
}
|
|
1287
1739
|
);
|
|
1288
1740
|
|
|
1289
1741
|
// The latest held era has no label yet (Ruby minted it, never
|
|
1290
1742
|
// named it) -- refuse rather than guess whether it matches.
|
|
1291
1743
|
let unnamed = vec![held(1, Some("aaa111")), held(2, None)];
|
|
1292
|
-
assert_eq!(
|
|
1744
|
+
assert_eq!(
|
|
1745
|
+
decide_boot_action(&unnamed, "ccc333"),
|
|
1746
|
+
BootDecision::LatestUnnamed { ordinal: 2 }
|
|
1747
|
+
);
|
|
1293
1748
|
}
|
|
1294
1749
|
|
|
1295
1750
|
// Proves Rust needs no Ruby to mint at all: real Postgres, zero Ruby
|
|
@@ -1305,21 +1760,47 @@ mod tests {
|
|
|
1305
1760
|
tokio::spawn(async move {
|
|
1306
1761
|
let _ = admin_connection.await;
|
|
1307
1762
|
});
|
|
1308
|
-
let _ = admin_client
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
admin_client
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
let
|
|
1763
|
+
let _ = admin_client
|
|
1764
|
+
.batch_execute(&format!("DROP DATABASE IF EXISTS {db} WITH (FORCE)"))
|
|
1765
|
+
.await;
|
|
1766
|
+
admin_client
|
|
1767
|
+
.batch_execute(&format!("CREATE DATABASE {db}"))
|
|
1768
|
+
.await
|
|
1769
|
+
.expect("create scratch db");
|
|
1770
|
+
let _ = admin_client
|
|
1771
|
+
.batch_execute(&format!("DROP ROLE IF EXISTS {owner}"))
|
|
1772
|
+
.await;
|
|
1773
|
+
admin_client
|
|
1774
|
+
.batch_execute(&format!(
|
|
1775
|
+
"CREATE ROLE {owner} {}",
|
|
1776
|
+
crate::test_pg::login_clause()
|
|
1777
|
+
))
|
|
1778
|
+
.await
|
|
1779
|
+
.expect("create owner role");
|
|
1780
|
+
admin_client
|
|
1781
|
+
.batch_execute(&format!("GRANT CONNECT ON DATABASE {db} TO {owner}"))
|
|
1782
|
+
.await
|
|
1783
|
+
.expect("grant connect");
|
|
1784
|
+
|
|
1785
|
+
let grant = tokio_postgres::connect(&crate::test_pg::conninfo(db), NoTls).await;
|
|
1786
|
+
let (grant_client, grant_connection) =
|
|
1787
|
+
grant.expect("connect to scratch db as superuser to grant schema rights");
|
|
1316
1788
|
tokio::spawn(async move {
|
|
1317
1789
|
let _ = grant_connection.await;
|
|
1318
1790
|
});
|
|
1319
|
-
grant_client
|
|
1320
|
-
|
|
1791
|
+
grant_client
|
|
1792
|
+
.batch_execute(&format!("GRANT USAGE, CREATE ON SCHEMA public TO {owner}"))
|
|
1793
|
+
.await
|
|
1794
|
+
.expect("grant schema rights");
|
|
1795
|
+
grant_client
|
|
1796
|
+
.batch_execute(&format!("ALTER DATABASE {db} OWNER TO {owner}"))
|
|
1797
|
+
.await
|
|
1798
|
+
.expect("make owner the db owner");
|
|
1321
1799
|
|
|
1322
|
-
let (client, connection) =
|
|
1800
|
+
let (client, connection) =
|
|
1801
|
+
tokio_postgres::connect(&crate::test_pg::conninfo_as(db, owner), NoTls)
|
|
1802
|
+
.await
|
|
1803
|
+
.expect("connect as owner");
|
|
1323
1804
|
tokio::spawn(async move {
|
|
1324
1805
|
let _ = connection.await;
|
|
1325
1806
|
});
|
|
@@ -1348,21 +1829,47 @@ mod tests {
|
|
|
1348
1829
|
let v2_ir = shape("amount");
|
|
1349
1830
|
|
|
1350
1831
|
// era 1: Rust mints it, no Ruby, no edge needed.
|
|
1351
|
-
let aggregate = Aggregate {
|
|
1352
|
-
|
|
1832
|
+
let aggregate = Aggregate {
|
|
1833
|
+
name: "Widget".to_string(),
|
|
1834
|
+
storage_name: "widget".to_string(),
|
|
1835
|
+
};
|
|
1836
|
+
hold_first(
|
|
1837
|
+
&client,
|
|
1838
|
+
domain,
|
|
1839
|
+
"v1 source text (opaque to this crate)",
|
|
1840
|
+
&v1_ir,
|
|
1841
|
+
std::slice::from_ref(&aggregate),
|
|
1842
|
+
None,
|
|
1843
|
+
)
|
|
1844
|
+
.await
|
|
1845
|
+
.expect("hold_first");
|
|
1353
1846
|
|
|
1354
|
-
let config = journal::LineageConfig {
|
|
1847
|
+
let config = journal::LineageConfig {
|
|
1848
|
+
domain: domain.to_string(),
|
|
1849
|
+
era: Some(1),
|
|
1850
|
+
mirrored: None,
|
|
1851
|
+
};
|
|
1355
1852
|
journal::append_lineage_mutation(
|
|
1356
1853
|
&client,
|
|
1357
1854
|
&config,
|
|
1358
|
-
&journal::Mutation {
|
|
1855
|
+
&journal::Mutation {
|
|
1856
|
+
aggregate: "Widget",
|
|
1857
|
+
id: "w1",
|
|
1858
|
+
operation: "save",
|
|
1859
|
+
state: &serde_json::json!({"cost": {"cents": 100}, "kind": {"label": "w1"}}),
|
|
1860
|
+
},
|
|
1359
1861
|
)
|
|
1360
1862
|
.await
|
|
1361
1863
|
.expect("write w1 under era 1");
|
|
1362
1864
|
journal::append_lineage_mutation(
|
|
1363
1865
|
&client,
|
|
1364
1866
|
&config,
|
|
1365
|
-
&journal::Mutation {
|
|
1867
|
+
&journal::Mutation {
|
|
1868
|
+
aggregate: "Widget",
|
|
1869
|
+
id: "w2",
|
|
1870
|
+
operation: "save",
|
|
1871
|
+
state: &serde_json::json!({"cost": {"cents": 200}, "kind": {"label": "w2"}}),
|
|
1872
|
+
},
|
|
1366
1873
|
)
|
|
1367
1874
|
.await
|
|
1368
1875
|
.expect("write w2 under era 1");
|
|
@@ -1378,31 +1885,53 @@ mod tests {
|
|
|
1378
1885
|
aggregates: vec![EdgeAggregate {
|
|
1379
1886
|
name: "Widget".to_string(),
|
|
1380
1887
|
was: None,
|
|
1381
|
-
has_compute_or_rekey: false,
|
|
1382
1888
|
compiled_state_expression: "hecks_tr_rename(state, 'cost', 'amount')".to_string(),
|
|
1383
1889
|
compiled_id_expression: None,
|
|
1384
1890
|
}],
|
|
1385
1891
|
};
|
|
1386
1892
|
|
|
1387
|
-
mint_era(
|
|
1388
|
-
|
|
1389
|
-
|
|
1893
|
+
mint_era(
|
|
1894
|
+
&client,
|
|
1895
|
+
domain,
|
|
1896
|
+
2,
|
|
1897
|
+
&storage_shape::mint_hash(&v2_ir),
|
|
1898
|
+
&to_label,
|
|
1899
|
+
"v2 source text",
|
|
1900
|
+
&[aggregate],
|
|
1901
|
+
&[&edge],
|
|
1902
|
+
None,
|
|
1903
|
+
&[],
|
|
1904
|
+
)
|
|
1905
|
+
.await
|
|
1906
|
+
.expect("mint_era");
|
|
1390
1907
|
|
|
1391
1908
|
// Read back through the same generic function real deployment traffic
|
|
1392
1909
|
// uses, proving the whole chain, not just that SQL ran.
|
|
1393
|
-
let mut rows = journal::read_lineage_head_all(&client, domain, "widget")
|
|
1910
|
+
let mut rows = journal::read_lineage_head_all(&client, domain, "widget")
|
|
1911
|
+
.await
|
|
1912
|
+
.expect("read_lineage_head_all");
|
|
1394
1913
|
rows.sort_by(|a, b| a.0.cmp(&b.0));
|
|
1395
1914
|
assert_eq!(
|
|
1396
1915
|
rows,
|
|
1397
1916
|
vec![
|
|
1398
|
-
(
|
|
1399
|
-
|
|
1917
|
+
(
|
|
1918
|
+
"w1".to_string(),
|
|
1919
|
+
serde_json::json!({"amount": {"cents": 100}, "kind": {"label": "w1"}})
|
|
1920
|
+
),
|
|
1921
|
+
(
|
|
1922
|
+
"w2".to_string(),
|
|
1923
|
+
serde_json::json!({"amount": {"cents": 200}, "kind": {"label": "w2"}})
|
|
1924
|
+
),
|
|
1400
1925
|
]
|
|
1401
1926
|
);
|
|
1402
1927
|
}
|
|
1403
1928
|
|
|
1404
1929
|
fn registration_default() -> LifecycleDefault {
|
|
1405
|
-
LifecycleDefault {
|
|
1930
|
+
LifecycleDefault {
|
|
1931
|
+
key_prefix: "Studio::Registration#".to_string(),
|
|
1932
|
+
field: "status".to_string(),
|
|
1933
|
+
default: "active".to_string(),
|
|
1934
|
+
}
|
|
1406
1935
|
}
|
|
1407
1936
|
|
|
1408
1937
|
// A seed as the kernel's `instances()` dump writes it: one entry per
|
|
@@ -1429,16 +1958,25 @@ mod tests {
|
|
|
1429
1958
|
#[test]
|
|
1430
1959
|
fn a_missing_lifecycle_field_gets_the_default() {
|
|
1431
1960
|
let mut seed = old_seed();
|
|
1432
|
-
assert_eq!(
|
|
1961
|
+
assert_eq!(
|
|
1962
|
+
fill_lifecycle_defaults(&mut seed, &[registration_default()]),
|
|
1963
|
+
2
|
|
1964
|
+
);
|
|
1433
1965
|
assert_eq!(seed["Studio::Registration#r1"]["status"], "active");
|
|
1434
1966
|
assert_eq!(seed["Studio::Registration#r2"]["status"], "active");
|
|
1435
|
-
assert_eq!(
|
|
1967
|
+
assert_eq!(
|
|
1968
|
+
seed["Studio::Registration#r1"]["attendee"]["email"], "ada@example.com",
|
|
1969
|
+
"everything else on the instance is kept"
|
|
1970
|
+
);
|
|
1436
1971
|
}
|
|
1437
1972
|
|
|
1438
1973
|
#[test]
|
|
1439
1974
|
fn a_null_lifecycle_field_is_filled_too() {
|
|
1440
1975
|
let mut seed = serde_json::json!({"Studio::Registration#r1": {"status": null}});
|
|
1441
|
-
assert_eq!(
|
|
1976
|
+
assert_eq!(
|
|
1977
|
+
fill_lifecycle_defaults(&mut seed, &[registration_default()]),
|
|
1978
|
+
1
|
|
1979
|
+
);
|
|
1442
1980
|
assert_eq!(seed["Studio::Registration#r1"]["status"], "active");
|
|
1443
1981
|
}
|
|
1444
1982
|
|
|
@@ -1446,7 +1984,10 @@ mod tests {
|
|
|
1446
1984
|
fn an_existing_lifecycle_value_is_left_alone() {
|
|
1447
1985
|
let mut seed = old_seed();
|
|
1448
1986
|
seed["Studio::Registration#r2"]["status"] = serde_json::json!("archived");
|
|
1449
|
-
assert_eq!(
|
|
1987
|
+
assert_eq!(
|
|
1988
|
+
fill_lifecycle_defaults(&mut seed, &[registration_default()]),
|
|
1989
|
+
1
|
|
1990
|
+
);
|
|
1450
1991
|
assert_eq!(seed["Studio::Registration#r2"]["status"], "archived");
|
|
1451
1992
|
assert_eq!(seed["Studio::Registration#r1"]["status"], "active");
|
|
1452
1993
|
}
|
|
@@ -1474,18 +2015,30 @@ mod tests {
|
|
|
1474
2015
|
let mut seed = old_seed();
|
|
1475
2016
|
fill_lifecycle_defaults(&mut seed, &[registration_default()]);
|
|
1476
2017
|
let once = seed.clone();
|
|
1477
|
-
assert_eq!(
|
|
2018
|
+
assert_eq!(
|
|
2019
|
+
fill_lifecycle_defaults(&mut seed, &[registration_default()]),
|
|
2020
|
+
0
|
|
2021
|
+
);
|
|
1478
2022
|
assert_eq!(seed, once);
|
|
1479
2023
|
}
|
|
1480
2024
|
|
|
1481
2025
|
#[test]
|
|
1482
2026
|
fn a_seed_that_is_not_an_object_or_holds_a_non_object_instance_is_left_alone() {
|
|
1483
2027
|
let mut empty = serde_json::json!({});
|
|
1484
|
-
assert_eq!(
|
|
2028
|
+
assert_eq!(
|
|
2029
|
+
fill_lifecycle_defaults(&mut empty, &[registration_default()]),
|
|
2030
|
+
0
|
|
2031
|
+
);
|
|
1485
2032
|
let mut not_a_seed = serde_json::json!([1, 2]);
|
|
1486
|
-
assert_eq!(
|
|
2033
|
+
assert_eq!(
|
|
2034
|
+
fill_lifecycle_defaults(&mut not_a_seed, &[registration_default()]),
|
|
2035
|
+
0
|
|
2036
|
+
);
|
|
1487
2037
|
let mut odd = serde_json::json!({"Studio::Registration#r1": "scalar"});
|
|
1488
|
-
assert_eq!(
|
|
2038
|
+
assert_eq!(
|
|
2039
|
+
fill_lifecycle_defaults(&mut odd, &[registration_default()]),
|
|
2040
|
+
0
|
|
2041
|
+
);
|
|
1489
2042
|
assert_eq!(odd["Studio::Registration#r1"], "scalar");
|
|
1490
2043
|
}
|
|
1491
2044
|
|
|
@@ -1503,7 +2056,11 @@ mod tests {
|
|
|
1503
2056
|
assert_eq!(
|
|
1504
2057
|
lifecycle_defaults(&ir),
|
|
1505
2058
|
vec![
|
|
1506
|
-
LifecycleDefault {
|
|
2059
|
+
LifecycleDefault {
|
|
2060
|
+
key_prefix: "Studio::Event#".to_string(),
|
|
2061
|
+
field: "status".to_string(),
|
|
2062
|
+
default: "open".to_string()
|
|
2063
|
+
},
|
|
1507
2064
|
registration_default(),
|
|
1508
2065
|
]
|
|
1509
2066
|
);
|
|
@@ -1515,7 +2072,9 @@ mod tests {
|
|
|
1515
2072
|
#[tokio::test]
|
|
1516
2073
|
async fn minting_an_era_fills_lifecycle_defaults_into_the_stored_snapshot() {
|
|
1517
2074
|
let client = own_scratch_db("hecks_host_mint_snapshot_fill").await;
|
|
1518
|
-
journal::ensure_schema(&client)
|
|
2075
|
+
journal::ensure_schema(&client)
|
|
2076
|
+
.await
|
|
2077
|
+
.expect("ensure_schema");
|
|
1519
2078
|
ensure_base(&client, "Studio").await.expect("ensure_base");
|
|
1520
2079
|
|
|
1521
2080
|
fn shape(with_status: bool) -> Value {
|
|
@@ -1527,14 +2086,29 @@ mod tests {
|
|
|
1527
2086
|
"entities": []
|
|
1528
2087
|
});
|
|
1529
2088
|
if with_status {
|
|
1530
|
-
aggregate["lifecycle"] =
|
|
2089
|
+
aggregate["lifecycle"] =
|
|
2090
|
+
serde_json::json!({"field": "status", "default": "active", "transitions": []});
|
|
1531
2091
|
}
|
|
1532
2092
|
serde_json::json!({"name": "Studio", "aggregates": [aggregate]})
|
|
1533
2093
|
}
|
|
1534
2094
|
let (v1_ir, v2_ir) = (shape(false), shape(true));
|
|
1535
|
-
let aggregate = Aggregate {
|
|
1536
|
-
|
|
1537
|
-
|
|
2095
|
+
let aggregate = Aggregate {
|
|
2096
|
+
name: "Registration".to_string(),
|
|
2097
|
+
storage_name: "registration".to_string(),
|
|
2098
|
+
};
|
|
2099
|
+
hold_first(
|
|
2100
|
+
&client,
|
|
2101
|
+
"Studio",
|
|
2102
|
+
"v1 source",
|
|
2103
|
+
&v1_ir,
|
|
2104
|
+
std::slice::from_ref(&aggregate),
|
|
2105
|
+
None,
|
|
2106
|
+
)
|
|
2107
|
+
.await
|
|
2108
|
+
.expect("hold_first");
|
|
2109
|
+
journal::save_snapshot(&client, 7, &old_seed())
|
|
2110
|
+
.await
|
|
2111
|
+
.expect("save_snapshot");
|
|
1538
2112
|
|
|
1539
2113
|
let to_label = storage_shape::mint_label(&v2_ir);
|
|
1540
2114
|
let edge = Edge {
|
|
@@ -1543,36 +2117,76 @@ mod tests {
|
|
|
1543
2117
|
aggregates: vec![EdgeAggregate {
|
|
1544
2118
|
name: "Registration".to_string(),
|
|
1545
2119
|
was: None,
|
|
1546
|
-
|
|
1547
|
-
|
|
2120
|
+
compiled_state_expression: "state || jsonb_build_object('status', 'active')"
|
|
2121
|
+
.to_string(),
|
|
1548
2122
|
compiled_id_expression: None,
|
|
1549
2123
|
}],
|
|
1550
2124
|
};
|
|
1551
2125
|
let defaults = lifecycle_defaults(&v2_ir);
|
|
1552
2126
|
assert_eq!(defaults.len(), 1);
|
|
1553
2127
|
// The defaults name "Studio::Registration#", matching the seed's keys.
|
|
1554
|
-
mint_era(
|
|
1555
|
-
|
|
1556
|
-
|
|
2128
|
+
mint_era(
|
|
2129
|
+
&client,
|
|
2130
|
+
"Studio",
|
|
2131
|
+
2,
|
|
2132
|
+
&storage_shape::mint_hash(&v2_ir),
|
|
2133
|
+
&to_label,
|
|
2134
|
+
"v2 source",
|
|
2135
|
+
&[aggregate],
|
|
2136
|
+
&[&edge],
|
|
2137
|
+
None,
|
|
2138
|
+
&defaults,
|
|
2139
|
+
)
|
|
2140
|
+
.await
|
|
2141
|
+
.expect("mint_era");
|
|
1557
2142
|
|
|
1558
|
-
let snapshot = journal::load_snapshot(&client)
|
|
1559
|
-
|
|
2143
|
+
let snapshot = journal::load_snapshot(&client)
|
|
2144
|
+
.await
|
|
2145
|
+
.unwrap()
|
|
2146
|
+
.expect("the snapshot row is still there");
|
|
2147
|
+
assert_eq!(
|
|
2148
|
+
snapshot.ordinal, 7,
|
|
2149
|
+
"the snapshot's ordinal is not touched, only its instances"
|
|
2150
|
+
);
|
|
1560
2151
|
assert_eq!(snapshot.seed["Studio::Registration#r1"]["status"], "active");
|
|
1561
2152
|
assert_eq!(snapshot.seed["Studio::Registration#r2"]["status"], "active");
|
|
1562
|
-
assert!(
|
|
2153
|
+
assert!(
|
|
2154
|
+
snapshot.seed["Studio::Event#yoga"]["status"] == "open"
|
|
2155
|
+
&& snapshot.seed["Payments::Payment#r1"]["status"] == "succeeded"
|
|
2156
|
+
);
|
|
1563
2157
|
}
|
|
1564
2158
|
|
|
1565
2159
|
#[tokio::test]
|
|
1566
2160
|
async fn filling_a_database_with_no_snapshot_table_or_row_is_a_no_op() {
|
|
1567
2161
|
let client = own_scratch_db("hecks_host_mint_snapshot_fill_absent").await;
|
|
1568
|
-
assert_eq!(
|
|
1569
|
-
|
|
1570
|
-
|
|
2162
|
+
assert_eq!(
|
|
2163
|
+
fill_snapshot_lifecycle_defaults(&client, "Studio", &[registration_default()])
|
|
2164
|
+
.await
|
|
2165
|
+
.unwrap(),
|
|
2166
|
+
0,
|
|
2167
|
+
"no table"
|
|
2168
|
+
);
|
|
2169
|
+
journal::ensure_schema(&client)
|
|
2170
|
+
.await
|
|
2171
|
+
.expect("ensure_schema");
|
|
2172
|
+
assert_eq!(
|
|
2173
|
+
fill_snapshot_lifecycle_defaults(&client, "Studio", &[registration_default()])
|
|
2174
|
+
.await
|
|
2175
|
+
.unwrap(),
|
|
2176
|
+
0,
|
|
2177
|
+
"no row"
|
|
2178
|
+
);
|
|
1571
2179
|
}
|
|
1572
2180
|
|
|
1573
2181
|
/// The compiled expression a backfill rule writes for one path, wrapped around `inner`.
|
|
1574
2182
|
fn backfill_expression(inner: &str, path: &[&str], default_json: &str) -> String {
|
|
1575
|
-
let array = format!(
|
|
2183
|
+
let array = format!(
|
|
2184
|
+
"ARRAY[{}]::text[]",
|
|
2185
|
+
path.iter()
|
|
2186
|
+
.map(|p| format!("'{p}'"))
|
|
2187
|
+
.collect::<Vec<_>>()
|
|
2188
|
+
.join(", ")
|
|
2189
|
+
);
|
|
1576
2190
|
let label = path.join(".");
|
|
1577
2191
|
format!(
|
|
1578
2192
|
"(SELECT CASE WHEN (hecks_tr_extract(__s, {array})).present THEN __s ELSE hecks_tr_insert(__s, {array}, '{default_json}'::jsonb, 'backfill {label}') END FROM (SELECT ({inner}) AS __s) __outer)"
|
|
@@ -1599,7 +2213,6 @@ mod tests {
|
|
|
1599
2213
|
aggregates: vec![EdgeAggregate {
|
|
1600
2214
|
name: aggregate.to_string(),
|
|
1601
2215
|
was: None,
|
|
1602
|
-
has_compute_or_rekey: false,
|
|
1603
2216
|
compiled_state_expression: expression.to_string(),
|
|
1604
2217
|
compiled_id_expression: None,
|
|
1605
2218
|
}],
|
|
@@ -1612,14 +2225,30 @@ mod tests {
|
|
|
1612
2225
|
#[test]
|
|
1613
2226
|
fn every_cte_of_the_chain_is_materialized() {
|
|
1614
2227
|
let watermarks = std::collections::HashMap::new();
|
|
1615
|
-
let edges: Vec<Edge> = (1..=6)
|
|
2228
|
+
let edges: Vec<Edge> = (1..=6)
|
|
2229
|
+
.map(|n| {
|
|
2230
|
+
edge_of(
|
|
2231
|
+
&format!("e{n}"),
|
|
2232
|
+
&format!("e{}", n + 1),
|
|
2233
|
+
"Registration",
|
|
2234
|
+
"state",
|
|
2235
|
+
)
|
|
2236
|
+
})
|
|
2237
|
+
.collect();
|
|
1616
2238
|
for n in 1..=6usize {
|
|
1617
2239
|
let chain: Vec<&Edge> = edges[..n].iter().collect();
|
|
1618
2240
|
let sql = chain_sql("Chain", "Registration", (n + 1) as i32, &chain, &watermarks);
|
|
1619
2241
|
assert!(sql.starts_with("WITH tail AS MATERIALIZED ("), "{sql}");
|
|
1620
|
-
assert_eq!(
|
|
2242
|
+
assert_eq!(
|
|
2243
|
+
sql.matches("AS MATERIALIZED (").count(),
|
|
2244
|
+
n + 1,
|
|
2245
|
+
"the tail and each of the {n} edges: {sql}"
|
|
2246
|
+
);
|
|
1621
2247
|
for edge in 1..=n {
|
|
1622
|
-
assert!(
|
|
2248
|
+
assert!(
|
|
2249
|
+
sql.contains(&format!("edge_{edge} AS MATERIALIZED (")),
|
|
2250
|
+
"edge {edge}: {sql}"
|
|
2251
|
+
);
|
|
1623
2252
|
}
|
|
1624
2253
|
}
|
|
1625
2254
|
}
|
|
@@ -1630,9 +2259,23 @@ mod tests {
|
|
|
1630
2259
|
#[tokio::test]
|
|
1631
2260
|
async fn the_chain_plan_grows_with_the_edges_not_exponentially() {
|
|
1632
2261
|
let client = own_scratch_db("hecks_host_mint_chain_plan_size").await;
|
|
1633
|
-
journal::ensure_schema(&client)
|
|
1634
|
-
|
|
1635
|
-
|
|
2262
|
+
journal::ensure_schema(&client)
|
|
2263
|
+
.await
|
|
2264
|
+
.expect("ensure_schema");
|
|
2265
|
+
let aggregates = vec![Aggregate {
|
|
2266
|
+
name: "Registration".to_string(),
|
|
2267
|
+
storage_name: "registration".to_string(),
|
|
2268
|
+
}];
|
|
2269
|
+
hold_first(
|
|
2270
|
+
&client,
|
|
2271
|
+
"Plan",
|
|
2272
|
+
"era 1",
|
|
2273
|
+
&serde_json::json!({"name": "Plan", "aggregates": []}),
|
|
2274
|
+
&aggregates,
|
|
2275
|
+
None,
|
|
2276
|
+
)
|
|
2277
|
+
.await
|
|
2278
|
+
.expect("hold_first");
|
|
1636
2279
|
|
|
1637
2280
|
let mut heavy = "hecks_tr_drop(state, ARRAY['attendee', 'name']::text[])".to_string();
|
|
1638
2281
|
for field in ["first_name", "last_name", "phone", "how_heard", "aim"] {
|
|
@@ -1640,24 +2283,53 @@ mod tests {
|
|
|
1640
2283
|
}
|
|
1641
2284
|
let mut edges = vec![edge_of("e1", "e2", "Registration", &heavy)];
|
|
1642
2285
|
for n in 2..=11 {
|
|
1643
|
-
edges.push(edge_of(
|
|
2286
|
+
edges.push(edge_of(
|
|
2287
|
+
&format!("e{n}"),
|
|
2288
|
+
&format!("e{}", n + 1),
|
|
2289
|
+
"Registration",
|
|
2290
|
+
"state",
|
|
2291
|
+
));
|
|
1644
2292
|
}
|
|
1645
|
-
edges.push(edge_of(
|
|
2293
|
+
edges.push(edge_of(
|
|
2294
|
+
"e12",
|
|
2295
|
+
"e13",
|
|
2296
|
+
"Registration",
|
|
2297
|
+
&backfill_expression("state", &["status"], "\"active\""),
|
|
2298
|
+
));
|
|
1646
2299
|
|
|
1647
2300
|
let watermarks = std::collections::HashMap::new();
|
|
1648
2301
|
let plan_lines = |chain: &[&Edge], materialize: bool| {
|
|
1649
|
-
let sql = chain_sql_with(
|
|
2302
|
+
let sql = chain_sql_with(
|
|
2303
|
+
"Plan",
|
|
2304
|
+
"Registration",
|
|
2305
|
+
(chain.len() + 1) as i32,
|
|
2306
|
+
chain,
|
|
2307
|
+
&watermarks,
|
|
2308
|
+
materialize,
|
|
2309
|
+
);
|
|
1650
2310
|
let client = &client;
|
|
1651
|
-
async move {
|
|
2311
|
+
async move {
|
|
2312
|
+
client
|
|
2313
|
+
.query(&format!("EXPLAIN (COSTS OFF) {sql}"), &[])
|
|
2314
|
+
.await
|
|
2315
|
+
.expect("explain")
|
|
2316
|
+
.len()
|
|
2317
|
+
}
|
|
1652
2318
|
};
|
|
1653
2319
|
|
|
1654
2320
|
let all: Vec<&Edge> = edges.iter().collect();
|
|
1655
2321
|
let fenced_12 = plan_lines(&all, true).await;
|
|
1656
2322
|
let fenced_6 = plan_lines(&all[..6], true).await;
|
|
1657
|
-
assert!(
|
|
2323
|
+
assert!(
|
|
2324
|
+
fenced_12 < 2 * fenced_6.max(500),
|
|
2325
|
+
"12 fenced edges plan to {fenced_12} lines, 6 to {fenced_6}"
|
|
2326
|
+
);
|
|
1658
2327
|
let inlined_5 = plan_lines(&all[..5], false).await;
|
|
1659
2328
|
let fenced_5 = plan_lines(&all[..5], true).await;
|
|
1660
|
-
assert!(
|
|
2329
|
+
assert!(
|
|
2330
|
+
inlined_5 > 10 * fenced_5,
|
|
2331
|
+
"unfenced 5 edges: {inlined_5} plan lines; fenced: {fenced_5}"
|
|
2332
|
+
);
|
|
1661
2333
|
}
|
|
1662
2334
|
|
|
1663
2335
|
/// Materializing each edge's CTE must not change one row of any chain: five
|
|
@@ -1666,14 +2338,24 @@ mod tests {
|
|
|
1666
2338
|
#[tokio::test]
|
|
1667
2339
|
async fn materializing_the_edge_ctes_returns_exactly_the_rows_the_inlined_chain_does() {
|
|
1668
2340
|
let client = own_scratch_db("hecks_host_mint_chain_differential").await;
|
|
1669
|
-
journal::ensure_schema(&client)
|
|
2341
|
+
journal::ensure_schema(&client)
|
|
2342
|
+
.await
|
|
2343
|
+
.expect("ensure_schema");
|
|
1670
2344
|
let domain = "Chain";
|
|
1671
2345
|
let aggregates = vec![
|
|
1672
|
-
Aggregate {
|
|
1673
|
-
|
|
2346
|
+
Aggregate {
|
|
2347
|
+
name: "Registration".to_string(),
|
|
2348
|
+
storage_name: "registration".to_string(),
|
|
2349
|
+
},
|
|
2350
|
+
Aggregate {
|
|
2351
|
+
name: "Event".to_string(),
|
|
2352
|
+
storage_name: "event".to_string(),
|
|
2353
|
+
},
|
|
1674
2354
|
];
|
|
1675
2355
|
let ir = serde_json::json!({"name": domain, "aggregates": []});
|
|
1676
|
-
hold_first(&client, domain, "era 1", &ir, &aggregates, None)
|
|
2356
|
+
hold_first(&client, domain, "era 1", &ir, &aggregates, None)
|
|
2357
|
+
.await
|
|
2358
|
+
.expect("hold_first");
|
|
1677
2359
|
|
|
1678
2360
|
// Edge 1 backfills four nested paths; edges 2 and 3 carry no rule (as
|
|
1679
2361
|
// most of a real deployment's edges do); edge 4 backfills one more.
|
|
@@ -1681,17 +2363,23 @@ mod tests {
|
|
|
1681
2363
|
for field in ["first_name", "last_name", "phone", "aim"] {
|
|
1682
2364
|
nested = backfill_expression(&nested, &["attendee", field], "\"(none)\"");
|
|
1683
2365
|
}
|
|
1684
|
-
let edges =
|
|
2366
|
+
let edges = [
|
|
1685
2367
|
edge_of("e1", "e2", "Registration", &nested),
|
|
1686
2368
|
edge_of("e2", "e3", "Event", "state"),
|
|
1687
2369
|
edge_of("e3", "e4", "Event", "state"),
|
|
1688
|
-
edge_of(
|
|
2370
|
+
edge_of(
|
|
2371
|
+
"e4",
|
|
2372
|
+
"e5",
|
|
2373
|
+
"Registration",
|
|
2374
|
+
&backfill_expression("state", &["status"], "\"active\""),
|
|
2375
|
+
),
|
|
1689
2376
|
];
|
|
1690
2377
|
|
|
1691
2378
|
let journal_name = "hecks_journal_chain";
|
|
1692
2379
|
let insert = |era: i32, aggregate: &str, id: &str, operation: &str, state: Value| {
|
|
1693
2380
|
let client = &client;
|
|
1694
|
-
let (aggregate, id, operation) =
|
|
2381
|
+
let (aggregate, id, operation) =
|
|
2382
|
+
(aggregate.to_string(), id.to_string(), operation.to_string());
|
|
1695
2383
|
async move {
|
|
1696
2384
|
client
|
|
1697
2385
|
.execute(
|
|
@@ -1704,36 +2392,86 @@ mod tests {
|
|
|
1704
2392
|
};
|
|
1705
2393
|
for era in 1..=5i32 {
|
|
1706
2394
|
// era 1 carries the old attendee shape, later eras the new one
|
|
1707
|
-
let attendee = |n: &str|
|
|
2395
|
+
let attendee = |n: &str| {
|
|
2396
|
+
if era == 1 {
|
|
2397
|
+
serde_json::json!({"name": n, "email": "a@example.com"})
|
|
2398
|
+
} else {
|
|
2399
|
+
serde_json::json!({"first_name": n, "email": "a@example.com"})
|
|
2400
|
+
}
|
|
2401
|
+
};
|
|
1708
2402
|
for i in 0..6 {
|
|
1709
2403
|
let id = format!("r{}", (era as usize + i) % 5);
|
|
1710
|
-
insert(
|
|
2404
|
+
insert(
|
|
2405
|
+
era,
|
|
2406
|
+
"registration",
|
|
2407
|
+
&id,
|
|
2408
|
+
"save",
|
|
2409
|
+
serde_json::json!({"attendee": attendee(&format!("n{era}{i}"))}),
|
|
2410
|
+
)
|
|
2411
|
+
.await;
|
|
1711
2412
|
}
|
|
1712
|
-
insert(
|
|
1713
|
-
|
|
2413
|
+
insert(
|
|
2414
|
+
era,
|
|
2415
|
+
"event",
|
|
2416
|
+
&format!("e{era}"),
|
|
2417
|
+
"save",
|
|
2418
|
+
serde_json::json!({"name": {"value": format!("event {era}")}}),
|
|
2419
|
+
)
|
|
2420
|
+
.await;
|
|
2421
|
+
insert(
|
|
2422
|
+
era,
|
|
2423
|
+
"event",
|
|
2424
|
+
"e1",
|
|
2425
|
+
"save",
|
|
2426
|
+
serde_json::json!({"name": {"value": "renamed"}}),
|
|
2427
|
+
)
|
|
2428
|
+
.await;
|
|
1714
2429
|
if era == 3 {
|
|
1715
2430
|
insert(era, "registration", "r0", "delete", Value::Null).await;
|
|
1716
2431
|
}
|
|
1717
2432
|
if era < 5 {
|
|
1718
2433
|
let chain: Vec<&Edge> = edges[..era as usize].iter().collect();
|
|
1719
|
-
mint_era(
|
|
2434
|
+
mint_era(
|
|
2435
|
+
&client,
|
|
2436
|
+
domain,
|
|
2437
|
+
era + 1,
|
|
2438
|
+
&"h".repeat(64),
|
|
2439
|
+
&format!("e{}", era + 1),
|
|
2440
|
+
"text",
|
|
2441
|
+
&aggregates,
|
|
2442
|
+
&chain,
|
|
2443
|
+
None,
|
|
2444
|
+
&[],
|
|
2445
|
+
)
|
|
2446
|
+
.await
|
|
2447
|
+
.expect("mint_era");
|
|
1720
2448
|
}
|
|
1721
2449
|
}
|
|
1722
2450
|
|
|
1723
2451
|
let held = journal::held_eras(&client, domain).await.unwrap();
|
|
1724
|
-
let watermarks: std::collections::HashMap<i32, Option<i64>> =
|
|
2452
|
+
let watermarks: std::collections::HashMap<i32, Option<i64>> =
|
|
2453
|
+
held.iter().map(|h| (h.ordinal, h.watermark)).collect();
|
|
1725
2454
|
let mut compared = 0;
|
|
1726
2455
|
for aggregate in &aggregates {
|
|
1727
2456
|
for n in 1..=edges.len() {
|
|
1728
2457
|
let chain: Vec<&Edge> = edges[..n].iter().collect();
|
|
1729
2458
|
let era = (n + 1) as i32;
|
|
1730
|
-
let inlined =
|
|
1731
|
-
|
|
2459
|
+
let inlined =
|
|
2460
|
+
chain_sql_with(domain, &aggregate.name, era, &chain, &watermarks, false);
|
|
2461
|
+
let fenced =
|
|
2462
|
+
chain_sql_with(domain, &aggregate.name, era, &chain, &watermarks, true);
|
|
1732
2463
|
assert_ne!(inlined, fenced, "the two forms really differ");
|
|
1733
2464
|
assert!(fenced.contains("AS MATERIALIZED (") && !inlined.contains("MATERIALIZED"));
|
|
1734
2465
|
|
|
1735
|
-
let (a, b) = (
|
|
1736
|
-
|
|
2466
|
+
let (a, b) = (
|
|
2467
|
+
raw_rows(&client, &inlined).await,
|
|
2468
|
+
raw_rows(&client, &fenced).await,
|
|
2469
|
+
);
|
|
2470
|
+
assert!(
|
|
2471
|
+
!a.is_empty(),
|
|
2472
|
+
"{} chain of {n}: the fixture yields rows",
|
|
2473
|
+
aggregate.name
|
|
2474
|
+
);
|
|
1737
2475
|
assert_eq!(a, b, "{} chain of {n}: raw rows", aggregate.name);
|
|
1738
2476
|
assert_eq!(
|
|
1739
2477
|
latest_of(&client, &inlined).await.unwrap(),
|
|
@@ -1746,9 +2484,25 @@ mod tests {
|
|
|
1746
2484
|
}
|
|
1747
2485
|
assert_eq!(compared, 8);
|
|
1748
2486
|
// and the fixture exercised what it claims to: a backfilled status reached the head
|
|
1749
|
-
let translated = translated_latest(
|
|
1750
|
-
|
|
1751
|
-
|
|
2487
|
+
let translated = translated_latest(
|
|
2488
|
+
&client,
|
|
2489
|
+
domain,
|
|
2490
|
+
"Registration",
|
|
2491
|
+
5,
|
|
2492
|
+
&edges.iter().collect::<Vec<_>>(),
|
|
2493
|
+
&watermarks,
|
|
2494
|
+
)
|
|
2495
|
+
.await
|
|
2496
|
+
.unwrap();
|
|
2497
|
+
assert!(
|
|
2498
|
+
translated.values().all(|state| state["status"] == "active"),
|
|
2499
|
+
"{translated:?}"
|
|
2500
|
+
);
|
|
2501
|
+
assert_eq!(
|
|
2502
|
+
translated.len(),
|
|
2503
|
+
5,
|
|
2504
|
+
"five registrations were ever saved: {translated:?}"
|
|
2505
|
+
);
|
|
1752
2506
|
}
|
|
1753
2507
|
|
|
1754
2508
|
// Layer 2, proven live both directions: an edge whose declared rules agree
|
|
@@ -1764,21 +2518,47 @@ mod tests {
|
|
|
1764
2518
|
tokio::spawn(async move {
|
|
1765
2519
|
let _ = admin_connection.await;
|
|
1766
2520
|
});
|
|
1767
|
-
let _ = admin_client
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
admin_client
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
let
|
|
2521
|
+
let _ = admin_client
|
|
2522
|
+
.batch_execute(&format!("DROP DATABASE IF EXISTS {db} WITH (FORCE)"))
|
|
2523
|
+
.await;
|
|
2524
|
+
admin_client
|
|
2525
|
+
.batch_execute(&format!("CREATE DATABASE {db}"))
|
|
2526
|
+
.await
|
|
2527
|
+
.expect("create scratch db");
|
|
2528
|
+
let _ = admin_client
|
|
2529
|
+
.batch_execute(&format!("DROP ROLE IF EXISTS {owner}"))
|
|
2530
|
+
.await;
|
|
2531
|
+
admin_client
|
|
2532
|
+
.batch_execute(&format!(
|
|
2533
|
+
"CREATE ROLE {owner} {}",
|
|
2534
|
+
crate::test_pg::login_clause()
|
|
2535
|
+
))
|
|
2536
|
+
.await
|
|
2537
|
+
.expect("create owner role");
|
|
2538
|
+
admin_client
|
|
2539
|
+
.batch_execute(&format!("GRANT CONNECT ON DATABASE {db} TO {owner}"))
|
|
2540
|
+
.await
|
|
2541
|
+
.expect("grant connect");
|
|
2542
|
+
|
|
2543
|
+
let grant = tokio_postgres::connect(&crate::test_pg::conninfo(db), NoTls).await;
|
|
2544
|
+
let (grant_client, grant_connection) =
|
|
2545
|
+
grant.expect("connect to scratch db as superuser to grant schema rights");
|
|
1775
2546
|
tokio::spawn(async move {
|
|
1776
2547
|
let _ = grant_connection.await;
|
|
1777
2548
|
});
|
|
1778
|
-
grant_client
|
|
1779
|
-
|
|
2549
|
+
grant_client
|
|
2550
|
+
.batch_execute(&format!("GRANT USAGE, CREATE ON SCHEMA public TO {owner}"))
|
|
2551
|
+
.await
|
|
2552
|
+
.expect("grant schema rights");
|
|
2553
|
+
grant_client
|
|
2554
|
+
.batch_execute(&format!("ALTER DATABASE {db} OWNER TO {owner}"))
|
|
2555
|
+
.await
|
|
2556
|
+
.expect("make owner the db owner");
|
|
1780
2557
|
|
|
1781
|
-
let (client, connection) =
|
|
2558
|
+
let (client, connection) =
|
|
2559
|
+
tokio_postgres::connect(&crate::test_pg::conninfo_as(db, owner), NoTls)
|
|
2560
|
+
.await
|
|
2561
|
+
.expect("connect as owner");
|
|
1782
2562
|
tokio::spawn(async move {
|
|
1783
2563
|
let _ = connection.await;
|
|
1784
2564
|
});
|
|
@@ -1803,17 +2583,43 @@ mod tests {
|
|
|
1803
2583
|
}
|
|
1804
2584
|
let v1_ir = shape("cost");
|
|
1805
2585
|
let v2_ir = shape("amount");
|
|
1806
|
-
let aggregate = Aggregate {
|
|
2586
|
+
let aggregate = Aggregate {
|
|
2587
|
+
name: "Widget".to_string(),
|
|
2588
|
+
storage_name: "widget".to_string(),
|
|
2589
|
+
};
|
|
1807
2590
|
let from_label = storage_shape::mint_label(&v1_ir);
|
|
1808
2591
|
let to_label = storage_shape::mint_label(&v2_ir);
|
|
1809
2592
|
|
|
1810
|
-
async fn seed_era_one(
|
|
1811
|
-
|
|
1812
|
-
|
|
2593
|
+
async fn seed_era_one(
|
|
2594
|
+
client: &tokio_postgres::Client,
|
|
2595
|
+
domain: &str,
|
|
2596
|
+
v1_ir: &Value,
|
|
2597
|
+
aggregate: &Aggregate,
|
|
2598
|
+
) {
|
|
2599
|
+
hold_first(
|
|
2600
|
+
client,
|
|
2601
|
+
domain,
|
|
2602
|
+
"v1 source text",
|
|
2603
|
+
v1_ir,
|
|
2604
|
+
std::slice::from_ref(aggregate),
|
|
2605
|
+
None,
|
|
2606
|
+
)
|
|
2607
|
+
.await
|
|
2608
|
+
.expect("hold_first");
|
|
2609
|
+
let config = journal::LineageConfig {
|
|
2610
|
+
domain: domain.to_string(),
|
|
2611
|
+
era: Some(1),
|
|
2612
|
+
mirrored: None,
|
|
2613
|
+
};
|
|
1813
2614
|
journal::append_lineage_mutation(
|
|
1814
2615
|
client,
|
|
1815
2616
|
&config,
|
|
1816
|
-
&journal::Mutation {
|
|
2617
|
+
&journal::Mutation {
|
|
2618
|
+
aggregate: "Widget",
|
|
2619
|
+
id: "w1",
|
|
2620
|
+
operation: "save",
|
|
2621
|
+
state: &serde_json::json!({"cost": {"cents": 100}, "kind": {"label": "w1"}}),
|
|
2622
|
+
},
|
|
1817
2623
|
)
|
|
1818
2624
|
.await
|
|
1819
2625
|
.expect("write w1 under era 1");
|
|
@@ -1829,7 +2635,6 @@ mod tests {
|
|
|
1829
2635
|
aggregates: vec![EdgeAggregate {
|
|
1830
2636
|
name: "Widget".to_string(),
|
|
1831
2637
|
was: None,
|
|
1832
|
-
has_compute_or_rekey: false,
|
|
1833
2638
|
compiled_state_expression: "hecks_tr_rename(state, 'cost', 'amount')".to_string(),
|
|
1834
2639
|
compiled_id_expression: None,
|
|
1835
2640
|
}],
|
|
@@ -1838,10 +2643,20 @@ mod tests {
|
|
|
1838
2643
|
"domain": honest_domain, "from": from_label, "to": to_label,
|
|
1839
2644
|
"aggregates": [{"name": "Widget", "renames": {"cost": "amount"}}]
|
|
1840
2645
|
})];
|
|
1841
|
-
let watermarks: std::collections::HashMap<i32, Option<i64>> =
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
2646
|
+
let watermarks: std::collections::HashMap<i32, Option<i64>> =
|
|
2647
|
+
std::iter::once((1, None)).collect();
|
|
2648
|
+
audit_before_mint(
|
|
2649
|
+
&client,
|
|
2650
|
+
honest_domain,
|
|
2651
|
+
&v2_ir,
|
|
2652
|
+
std::slice::from_ref(&aggregate),
|
|
2653
|
+
2,
|
|
2654
|
+
&[&honest_edge],
|
|
2655
|
+
&honest_raw_edges,
|
|
2656
|
+
&watermarks,
|
|
2657
|
+
)
|
|
2658
|
+
.await
|
|
2659
|
+
.expect("an honest edge's audit must pass silently");
|
|
1845
2660
|
|
|
1846
2661
|
// The lying edge: declared `renames:` claims a different destination
|
|
1847
2662
|
// than the compiled SQL produces — the exact bug this audit exists to
|
|
@@ -1855,11 +2670,23 @@ mod tests {
|
|
|
1855
2670
|
// still renames it to `amount`. A real divergence.
|
|
1856
2671
|
"aggregates": [{"name": "Widget", "renames": {"cost": "price"}}]
|
|
1857
2672
|
})];
|
|
1858
|
-
let error = audit_before_mint(
|
|
1859
|
-
|
|
1860
|
-
|
|
2673
|
+
let error = audit_before_mint(
|
|
2674
|
+
&client,
|
|
2675
|
+
lying_domain,
|
|
2676
|
+
&v2_ir,
|
|
2677
|
+
&[aggregate],
|
|
2678
|
+
2,
|
|
2679
|
+
&[&lying_edge],
|
|
2680
|
+
&lying_raw_edges,
|
|
2681
|
+
&watermarks,
|
|
2682
|
+
)
|
|
2683
|
+
.await
|
|
2684
|
+
.expect_err("a declared rename that disagrees with the compiled SQL must refuse");
|
|
1861
2685
|
let message = format!("{error:#}");
|
|
1862
2686
|
assert!(message.contains("the audit refused"), "{message}");
|
|
1863
|
-
assert!(
|
|
2687
|
+
assert!(
|
|
2688
|
+
message.contains("diverges from the reference transform"),
|
|
2689
|
+
"{message}"
|
|
2690
|
+
);
|
|
1864
2691
|
}
|
|
1865
2692
|
}
|