hecks 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/claude_code.rb +3 -3
- data/lib/hecks/adapters/driven/d1.rb +48 -28
- data/lib/hecks/adapters/driven/folder.rb +10 -3
- data/lib/hecks/adapters/driven/google_authentication.rb +2 -4
- data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
- data/lib/hecks/adapters/driven/heki.rb +3 -5
- data/lib/hecks/adapters/driven/in_memory_ordering.rb +4 -1
- data/lib/hecks/adapters/driven/lambda.rb +2 -2
- data/lib/hecks/adapters/driven/local_storage.adapter +3 -0
- data/lib/hecks/adapters/driven/local_storage.rb +120 -0
- data/lib/hecks/adapters/driven/memory.rb +48 -3
- data/lib/hecks/adapters/driven/postgres/codec.rb +3 -1
- data/lib/hecks/adapters/driven/postgres/outbox.rb +97 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +3 -2
- data/lib/hecks/adapters/driven/postgres.rb +25 -14
- data/lib/hecks/adapters/driven/prism.rb +11 -0
- data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
- data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
- data/lib/hecks/adapters/driven/sqlite/projection.rb +17 -1
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
- data/lib/hecks/adapters/driven/sqlite.rb +87 -8
- data/lib/hecks/adapters/driven.rb +4 -0
- data/lib/hecks/adapters.rb +4 -0
- data/lib/hecks/behaviors/dsl.rb +7 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/behaviors/rspec.rb +2 -0
- data/lib/hecks/behaviors/runner.rb +4 -0
- data/lib/hecks/bluebook/aggregate.rb +6 -5
- data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
- data/lib/hecks/bluebook/assembly/build.rb +6 -3
- data/lib/hecks/bluebook/assembly/contract.rb +5 -1
- data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
- data/lib/hecks/bluebook/assembly/marks.rb +2 -1
- data/lib/hecks/bluebook/assembly.rb +1 -3
- data/lib/hecks/bluebook/attribute.rb +6 -0
- data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
- data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
- data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
- data/lib/hecks/bluebook/command.rb +6 -5
- data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +19 -472
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
- data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
- data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
- data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
- data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
- data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
- data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
- data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +11 -3
- data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
- data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
- data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
- data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
- data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
- data/lib/hecks/bluebook/entity.rb +5 -3
- data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
- data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
- data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
- data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
- data/lib/hecks/bluebook/expression/projection.json +12 -0
- data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
- data/lib/hecks/bluebook/expression/resolver.rb +83 -11
- data/lib/hecks/bluebook/expression.rb +1 -0
- data/lib/hecks/bluebook/hexagon.rb +10 -0
- data/lib/hecks/bluebook/lifecycle.rb +9 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
- data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
- data/lib/hecks/bluebook/meta_validator.rb +5 -5
- data/lib/hecks/bluebook/model_check.rb +119 -82
- data/lib/hecks/bluebook/pattern_subset.rb +9 -0
- data/lib/hecks/bluebook/policy.rb +2 -1
- data/lib/hecks/bluebook/process_manager.rb +6 -0
- data/lib/hecks/bluebook/project_register.rb +45 -25
- data/lib/hecks/bluebook/query.rb +4 -0
- data/lib/hecks/bluebook/read_model.rb +2 -2
- data/lib/hecks/bluebook/smoke_test.rb +5 -0
- data/lib/hecks/bluebook/synthesizer.rb +2 -1
- data/lib/hecks/bluebook/translation.rb +3 -0
- data/lib/hecks/bluebook/value_object.rb +2 -2
- data/lib/hecks/codemod.rb +13 -0
- data/lib/hecks/doc/reference.rb +17 -11
- data/lib/hecks/facade/cli_runner.rb +2 -2
- data/lib/hecks/facade/command_request.rb +11 -11
- data/lib/hecks/facade/handle.rb +3 -3
- data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
- data/lib/hecks/facade.rb +4 -0
- data/lib/hecks/forms/app.rb +10 -6
- data/lib/hecks/forms/command_form_renderer.rb +3 -3
- data/lib/hecks/forms/field_renderer.rb +14 -9
- data/lib/hecks/forms/field_shape.rb +7 -2
- data/lib/hecks/forms/html.rb +5 -2
- data/lib/hecks/forms/index_renderer.rb +1 -1
- data/lib/hecks/forms/page.rb +7 -3
- data/lib/hecks/forms/query_form_renderer.rb +3 -2
- data/lib/hecks/forms/record_renderer.rb +22 -16
- data/lib/hecks/forms.rb +4 -0
- data/lib/hecks/fqn.rb +21 -9
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
- data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
- data/lib/hecks/fuzzing/properties/guards.rb +251 -0
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
- data/lib/hecks/fuzzing/properties/querying.rb +206 -0
- data/lib/hecks/fuzzing/properties.rb +41 -1065
- data/lib/hecks/fuzzing/replay.rb +60 -16
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
- data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
- data/lib/hecks/fuzzing/value_generator.rb +2 -2
- data/lib/hecks/grammar/evolve.rb +34 -10
- data/lib/hecks/grammar/expression_operators.json +124 -0
- data/lib/hecks/grammar.rb +15 -5
- data/lib/hecks/ir.rb +9 -0
- data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
- data/lib/hecks/language/bluebook/command.bluebook +2 -1
- data/lib/hecks/language/bluebook/entity.bluebook +2 -1
- data/lib/hecks/language/bluebook/projection.bluebook +2 -1
- data/lib/hecks/language/bluebook/query.bluebook +4 -2
- data/lib/hecks/language/bluebook/shape.bluebook +2 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
- data/lib/hecks/literal.rb +18 -8
- data/lib/hecks/naming.rb +6 -3
- data/lib/hecks/ports/access_control.rb +1 -1
- data/lib/hecks/ports/agent.rb +5 -3
- data/lib/hecks/ports/authentication.rb +3 -2
- data/lib/hecks/ports/authorization.rb +1 -1
- data/lib/hecks/ports/clock.rb +1 -1
- data/lib/hecks/ports/extraction.rb +5 -1
- data/lib/hecks/ports/identity_assignment.rb +1 -1
- data/lib/hecks/ports/identity_generation.rb +1 -1
- data/lib/hecks/ports/identity_resolution.rb +1 -1
- data/lib/hecks/ports/loading.rb +4 -1
- data/lib/hecks/ports/persistence/append_only.rb +40 -4
- data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
- data/lib/hecks/ports/persistence/plugin.rb +3 -0
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
- data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +23 -13
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
- data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
- data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
- data/lib/hecks/ports/persistence.rb +7 -3
- data/lib/hecks/ports/projection.rb +13 -5
- data/lib/hecks/ports/query/in_memory.rb +8 -2
- data/lib/hecks/ports/query.rb +1 -3
- data/lib/hecks/ports.rb +2 -0
- data/lib/hecks/projections/diagrams.rb +11 -3
- data/lib/hecks/projections/ir.rb +2 -0
- data/lib/hecks/projections/model/deviations.rb +2 -1
- data/lib/hecks/projections/model.rb +15 -10
- data/lib/hecks/projections/vocabulary.rb +1 -3
- data/lib/hecks/projector/cli_projector.rb +49 -29
- data/lib/hecks/projector/docs_projector.rb +21 -12
- data/lib/hecks/projector/exporter.rb +10 -1
- data/lib/hecks/projector/narrate_projector.rb +83 -25
- data/lib/hecks/projector.rb +2 -2
- data/lib/hecks/query_ir.rb +60 -36
- data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
- data/lib/hecks/query_specification/common/comparators.rb +4 -0
- data/lib/hecks/query_specification/common/comparison.rb +8 -1
- data/lib/hecks/query_specification/common/dsl.rb +5 -0
- data/lib/hecks/query_specification/common/null_policy.rb +6 -2
- data/lib/hecks/query_specification/common/options.rb +17 -5
- data/lib/hecks/query_specification/field_path.rb +4 -4
- data/lib/hecks/query_specification/read_model/specification.rb +5 -2
- data/lib/hecks/router/namespace_installer.rb +22 -10
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
- data/lib/hecks/runtime/command_interpreter.rb +104 -65
- data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
- data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
- data/lib/hecks/runtime/dependency_planning.rb +33 -21
- data/lib/hecks/runtime/dispatcher.rb +41 -7
- data/lib/hecks/runtime/entity_element.rb +25 -6
- data/lib/hecks/runtime/entity_interpreter.rb +28 -15
- data/lib/hecks/runtime/errors.rb +28 -2
- data/lib/hecks/runtime/instance.rb +9 -3
- data/lib/hecks/runtime/interpreting.rb +29 -2
- data/lib/hecks/runtime/loader.rb +21 -0
- data/lib/hecks/runtime/outbox.rb +365 -0
- data/lib/hecks/runtime/policy_interpreter.rb +32 -17
- data/lib/hecks/runtime/query_interpreter.rb +36 -16
- data/lib/hecks/runtime/reaction_invocation.rb +35 -22
- data/lib/hecks/runtime/read_model_interpreter.rb +26 -3
- data/lib/hecks/runtime/reference_hop.rb +1 -1
- data/lib/hecks/runtime/refusal_wording.rb +55 -49
- data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
- data/lib/hecks/runtime/registry/verification.rb +39 -2
- data/lib/hecks/runtime/registry.rb +21 -11
- data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
- data/lib/hecks/runtime/routing.rb +52 -39
- data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
- data/lib/hecks/runtime/saga_interpreter.rb +119 -90
- data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
- data/lib/hecks/runtime/tenant_scope.rb +7 -0
- data/lib/hecks/runtime/value/coercion.rb +207 -48
- data/lib/hecks/runtime/value.rb +1 -1
- data/lib/hecks/runtime.rb +7 -2
- data/lib/hecks/storehouse.rb +15 -8
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +1 -0
- data/lib/hecks.rb +17 -13
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
- metadata +14 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e2957772a5d25ada566a632461d6aa1829b6fb8016d4b563377ba7c856c1ed7d
|
|
4
|
+
data.tar.gz: 0e92b2b30420c37c8b92c087e26873223e47cdadcf88d7cbed1583b4a39729ee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7a9641aeac769fa7e43131942276f8d5acf121daa2adc5b9d9e1375d5f509588ed3a5bd09b6995bb4c1214dd074dfe27666e8490a44e53f7042131a22f3fd8cd
|
|
7
|
+
data.tar.gz: 6cfb8e4e7852d8cbeaf417cf20ae952a94df5d3c12acf79cb8f4c4a0e454ca01a1e04d17956fa2de4f359776f3b6c3b66b199a0fa6eb8fcf7b4389bcdfc834d8
|
|
@@ -36,7 +36,7 @@ module Hecks
|
|
|
36
36
|
|
|
37
37
|
SYSTEM_PREFIX = "You are assisting a domain-modeling interview for the hecks " \
|
|
38
38
|
"event-sourced framework. Reply with EXACTLY ONE JSON object, no prose " \
|
|
39
|
-
"before or after it, no markdown code fence. "
|
|
39
|
+
"before or after it, no markdown code fence. ".freeze
|
|
40
40
|
|
|
41
41
|
module_function
|
|
42
42
|
|
|
@@ -97,13 +97,13 @@ module Hecks
|
|
|
97
97
|
# ── transport ───────────────────────────────────────────────────
|
|
98
98
|
|
|
99
99
|
def call(system:, payload:)
|
|
100
|
-
stdout, status = Timeout.timeout(TIMEOUT_SECONDS)
|
|
100
|
+
stdout, status = Timeout.timeout(TIMEOUT_SECONDS) do
|
|
101
101
|
Open3.capture2(
|
|
102
102
|
"claude", "-p", "--output-format", "json",
|
|
103
103
|
"--append-system-prompt", system, "--allowedTools", "",
|
|
104
104
|
stdin_data: JSON.generate(payload)
|
|
105
105
|
)
|
|
106
|
-
|
|
106
|
+
end
|
|
107
107
|
raise Ports::Agent::Unavailable, "claude exited #{status.exitstatus}: #{stdout}" unless status.success?
|
|
108
108
|
|
|
109
109
|
unwrap(stdout)
|
|
@@ -31,7 +31,7 @@ module Hecks
|
|
|
31
31
|
# per execute, not a persistent connection — D1 has no connection to
|
|
32
32
|
# hold open.
|
|
33
33
|
class Connection
|
|
34
|
-
ENDPOINT = "https://api.cloudflare.com/client/v4"
|
|
34
|
+
ENDPOINT = "https://api.cloudflare.com/client/v4".freeze
|
|
35
35
|
|
|
36
36
|
def initialize(account_id:, database_id:, api_token:)
|
|
37
37
|
@uri = URI("#{ENDPOINT}/accounts/#{account_id}/d1/database/#{database_id}/query")
|
|
@@ -65,36 +65,41 @@ module Hecks
|
|
|
65
65
|
request.body = JSON.generate(payload)
|
|
66
66
|
|
|
67
67
|
response = Net::HTTP.start(@uri.host, @uri.port, use_ssl: true) { |http| http.request(request) }
|
|
68
|
-
body =
|
|
69
|
-
|
|
70
|
-
JSON.parse(response.body)
|
|
71
|
-
rescue JSON::ParserError
|
|
72
|
-
raise Runtime::WiringError, "D1 query failed: non-JSON response (HTTP #{response.code}): #{response.body}"
|
|
73
|
-
end
|
|
68
|
+
body = parse_d1_body(response)
|
|
69
|
+
messages = (body["errors"] || []).map { |error| error["message"] }.join("; ")
|
|
74
70
|
|
|
75
|
-
unless body["success"]
|
|
76
|
-
messages = (body["errors"] || []).map { |error| error["message"] }.join("; ")
|
|
77
|
-
raise Runtime::WiringError, "D1 query failed: #{messages.empty? ? response.body : messages}"
|
|
78
|
-
end
|
|
71
|
+
raise Runtime::WiringError, "D1 query failed: #{messages.empty? ? response.body : messages}" unless body["success"]
|
|
79
72
|
|
|
80
73
|
results = body.fetch("result")
|
|
81
74
|
failed = results.find { |result| result["success"] == false }
|
|
82
|
-
if failed
|
|
83
|
-
messages = (body["errors"] || []).map { |error| error["message"] }.join("; ")
|
|
84
|
-
detail =
|
|
85
|
-
if failed.key?("error")
|
|
86
|
-
failed["error"]
|
|
87
|
-
elsif failed.key?("message")
|
|
88
|
-
failed["message"]
|
|
89
|
-
else
|
|
90
|
-
messages
|
|
91
|
-
end
|
|
92
|
-
raise Runtime::WiringError, "D1 query failed: #{detail.to_s.empty? ? 'a batched statement failed' : detail}"
|
|
93
|
-
end
|
|
75
|
+
raise Runtime::WiringError, "D1 query failed: #{failed_statement_detail(failed, messages)}" if failed
|
|
94
76
|
|
|
95
77
|
results
|
|
96
78
|
end
|
|
97
79
|
|
|
80
|
+
def parse_d1_body(response)
|
|
81
|
+
JSON.parse(response.body)
|
|
82
|
+
rescue JSON::ParserError
|
|
83
|
+
raise Runtime::WiringError, "D1 query failed: non-JSON response (HTTP #{response.code}): #{response.body}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# `messages` is the whole-response :errors fallback, tried LAST —
|
|
87
|
+
# a per-statement `failed["error"]`/`failed["message"]` (checked
|
|
88
|
+
# by key presence, not truthiness, so an explicit `nil` still
|
|
89
|
+
# counts as "the key was there") is always more specific to WHICH
|
|
90
|
+
# statement failed, when either is present.
|
|
91
|
+
def failed_statement_detail(failed, messages)
|
|
92
|
+
detail =
|
|
93
|
+
if failed.key?("error")
|
|
94
|
+
failed["error"]
|
|
95
|
+
elsif failed.key?("message")
|
|
96
|
+
failed["message"]
|
|
97
|
+
else
|
|
98
|
+
messages
|
|
99
|
+
end
|
|
100
|
+
detail.to_s.empty? ? "a batched statement failed" : detail
|
|
101
|
+
end
|
|
102
|
+
|
|
98
103
|
public
|
|
99
104
|
|
|
100
105
|
def get_first_row(sql, binds = [])
|
|
@@ -170,7 +175,10 @@ module Hecks
|
|
|
170
175
|
order_sql = "ORDER BY id"
|
|
171
176
|
if order_by
|
|
172
177
|
name = order_by.to_s.split(".").first
|
|
173
|
-
|
|
178
|
+
unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name)
|
|
179
|
+
raise Runtime::WiringError,
|
|
180
|
+
"#{@aggregate.name} has no attribute #{order_by.inspect} to order by"
|
|
181
|
+
end
|
|
174
182
|
|
|
175
183
|
spec = QuerySpecification::Common::OrderBy.new(field: order_by, direction: direction)
|
|
176
184
|
order_sql = "ORDER BY #{order_clause(spec, nil)}"
|
|
@@ -256,6 +264,16 @@ module Hecks
|
|
|
256
264
|
# matching Sqlite#atomic_put's `next` (skip append AND project both,
|
|
257
265
|
# together) with no second HTTP call and no gap for another writer to
|
|
258
266
|
# land in between the check and the write.
|
|
267
|
+
# Three SQL statements, built here and batched together as ONE
|
|
268
|
+
# transaction below — see the comment above on the real TOCTOU gap
|
|
269
|
+
# this exact shape closes (the existence check moved INSIDE the
|
|
270
|
+
# batch, not run as a separate earlier round trip). Splitting the
|
|
271
|
+
# per-statement builders out would still need columns/values/slots/
|
|
272
|
+
# not_exists/quoted_table threaded into each, and would separate
|
|
273
|
+
# three pieces of ONE atomic batch across methods with no single
|
|
274
|
+
# place left to see that they are, together, the fix.
|
|
275
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
276
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
259
277
|
def atomic_put(entry, insert_only: false)
|
|
260
278
|
instance = Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: entry.state)
|
|
261
279
|
columns = (["id"] + persisted_fields.map { |field| field[:name].to_s }).map { |column| quote_ident(column) }
|
|
@@ -343,9 +361,10 @@ module Hecks
|
|
|
343
361
|
# already uses through `Connection#execute`.
|
|
344
362
|
def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
|
|
345
363
|
@db.execute(
|
|
346
|
-
"INSERT OR REPLACE INTO hecks_saga_instances (domain, process_manager, correlation, state, memory,
|
|
347
|
-
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
348
|
-
[@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory),
|
|
364
|
+
"INSERT OR REPLACE INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, " \
|
|
365
|
+
"completed_compensations) VALUES (?, ?, ?, ?, ?, ?)",
|
|
366
|
+
[@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory),
|
|
367
|
+
JSON.generate(completed_compensations)]
|
|
349
368
|
)
|
|
350
369
|
end
|
|
351
370
|
|
|
@@ -360,7 +379,8 @@ module Hecks
|
|
|
360
379
|
return enum_for(:each_saga) unless block_given?
|
|
361
380
|
|
|
362
381
|
@db.execute(
|
|
363
|
-
"SELECT process_manager, correlation, state, memory, completed_compensations
|
|
382
|
+
"SELECT process_manager, correlation, state, memory, completed_compensations " \
|
|
383
|
+
"FROM hecks_saga_instances WHERE domain = ?",
|
|
364
384
|
[@domain]
|
|
365
385
|
).each do |row|
|
|
366
386
|
yield row["process_manager"], row["correlation"], row["state"],
|
|
@@ -2,10 +2,17 @@ require_relative "../../vocabulary"
|
|
|
2
2
|
|
|
3
3
|
module Hecks
|
|
4
4
|
module Adapters
|
|
5
|
+
# The filesystem-backed loader — glob-and-`Kernel.load` a domain's own
|
|
6
|
+
# `.port`/`.adapter`/`.bluebook`/`.hecksagon`/`.world`/environment-overlay
|
|
7
|
+
# files off disk, in the order `Vocabulary.fetch("LoadOrder")` requires,
|
|
8
|
+
# and locate a domain's own root directory by walking up from any file
|
|
9
|
+
# inside it looking for a `.hecksagon`. What `Hecks.boot(path)` uses when
|
|
10
|
+
# a caller names a directory rather than an explicit file list (see
|
|
11
|
+
# `Loader.boot_files` for that other path).
|
|
5
12
|
class Folder
|
|
6
13
|
DOMAIN_ORDER = Hecks::Vocabulary.fetch("LoadOrder")
|
|
7
|
-
PORTS = "ports"
|
|
8
|
-
ADAPTERS = "adapters"
|
|
14
|
+
PORTS = "ports".freeze
|
|
15
|
+
ADAPTERS = "adapters".freeze
|
|
9
16
|
|
|
10
17
|
def initialize(settings: {}, root: nil)
|
|
11
18
|
@settings = settings
|
|
@@ -117,7 +124,7 @@ module Hecks
|
|
|
117
124
|
return unless File.directory?(directory)
|
|
118
125
|
|
|
119
126
|
patterns.each do |pattern|
|
|
120
|
-
Dir[File.join(directory, pattern)].
|
|
127
|
+
Dir[File.join(directory, pattern)].each { |file| Kernel.load(file) }
|
|
121
128
|
end
|
|
122
129
|
end
|
|
123
130
|
|
|
@@ -30,7 +30,7 @@ module Hecks
|
|
|
30
30
|
# no defaults, on purpose: a login mechanism silently
|
|
31
31
|
# half-configured is worse than one that refuses to boot at all.
|
|
32
32
|
module GoogleAuthentication
|
|
33
|
-
ISSUER = "https://accounts.google.com"
|
|
33
|
+
ISSUER = "https://accounts.google.com".freeze
|
|
34
34
|
|
|
35
35
|
module_function
|
|
36
36
|
|
|
@@ -80,9 +80,7 @@ module Hecks
|
|
|
80
80
|
require "oauth2"
|
|
81
81
|
require "google-id-token"
|
|
82
82
|
|
|
83
|
-
unless state && expected_state && state == expected_state
|
|
84
|
-
raise Ports::Authentication::ValidationError, "state mismatch"
|
|
85
|
-
end
|
|
83
|
+
raise Ports::Authentication::ValidationError, "state mismatch" unless state && expected_state && state == expected_state
|
|
86
84
|
|
|
87
85
|
token = client.auth_code.get_token(code, redirect_uri: ENV.fetch("GOOGLE_REDIRECT_URI"))
|
|
88
86
|
id_token = token.params["id_token"] or raise Ports::Authentication::ValidationError, "no id_token in the response"
|
|
@@ -29,7 +29,7 @@ module Hecks
|
|
|
29
29
|
case entry.fetch("operation")
|
|
30
30
|
when "save" then records[id] = entry.fetch("state")
|
|
31
31
|
when "delete" then records.delete(id)
|
|
32
|
-
else raise Malformed, "#{@journal_path}: unknown journal operation #{entry.fetch(
|
|
32
|
+
else raise Malformed, "#{@journal_path}: unknown journal operation #{entry.fetch('operation').inspect}"
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
records
|
|
@@ -18,12 +18,12 @@ module Hecks
|
|
|
18
18
|
include Snapshot
|
|
19
19
|
include Journal
|
|
20
20
|
|
|
21
|
-
MAGIC = "HEKI"
|
|
21
|
+
MAGIC = "HEKI".freeze
|
|
22
22
|
HEADER_BYTES = 8
|
|
23
23
|
|
|
24
24
|
class Malformed < StandardError; end
|
|
25
25
|
|
|
26
|
-
attr_reader :aggregate, :path
|
|
26
|
+
attr_reader :aggregate, :path, :events
|
|
27
27
|
|
|
28
28
|
def initialize(aggregate:, settings: {}, root: nil)
|
|
29
29
|
@aggregate = aggregate
|
|
@@ -114,8 +114,6 @@ module Hecks
|
|
|
114
114
|
|
|
115
115
|
def record_event(event) = @events << event
|
|
116
116
|
|
|
117
|
-
def events = @events
|
|
118
|
-
|
|
119
117
|
# ── the OPTIONAL saga-persistence capability (§2) — Heki's own
|
|
120
118
|
# shape (a sibling snapshot+journal file pair, `SagaStore`,
|
|
121
119
|
# heki/saga_store.rb) rather than a table in a store this adapter
|
|
@@ -128,7 +126,7 @@ module Hecks
|
|
|
128
126
|
saga_store.delete_saga(@domain, process_manager.to_s, correlation.to_s)
|
|
129
127
|
end
|
|
130
128
|
|
|
131
|
-
def each_saga(&
|
|
129
|
+
def each_saga(&) = saga_store.each_saga(@domain, &)
|
|
132
130
|
|
|
133
131
|
private
|
|
134
132
|
|
|
@@ -24,7 +24,10 @@ module Hecks
|
|
|
24
24
|
return records unless order_by
|
|
25
25
|
|
|
26
26
|
name = order_by.to_s.split(".").first
|
|
27
|
-
|
|
27
|
+
unless aggregate.lifecycle&.field.to_s == name || aggregate.attribute(name)
|
|
28
|
+
raise Runtime::WiringError,
|
|
29
|
+
"#{aggregate.name} has no attribute #{order_by.inspect} to order by"
|
|
30
|
+
end
|
|
28
31
|
|
|
29
32
|
path = sortable_path(aggregate, order_by)
|
|
30
33
|
spec = QuerySpecification::Common::OrderBy.new(field: order_by, direction: direction)
|
|
@@ -123,11 +123,11 @@ module Hecks
|
|
|
123
123
|
# full "Domain::Aggregate#id" string — callers already know which
|
|
124
124
|
# aggregate they're asking this instance about.
|
|
125
125
|
def instances
|
|
126
|
-
@client.read.fetch("instances", {}).filter_map
|
|
126
|
+
@client.read.fetch("instances", {}).filter_map do |key, state|
|
|
127
127
|
next unless key.start_with?(@prefix)
|
|
128
128
|
|
|
129
129
|
[key.delete_prefix(@prefix), build_instance(key.delete_prefix(@prefix), state)]
|
|
130
|
-
|
|
130
|
+
end.to_h
|
|
131
131
|
end
|
|
132
132
|
|
|
133
133
|
# Lambda's own JSON response is already Ruby-decoded with STRING
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
require_relative "../../ports/persistence/append_only"
|
|
2
|
+
require_relative "../../ports/query/in_memory"
|
|
3
|
+
require_relative "in_memory_ordering"
|
|
4
|
+
require_relative "../../runtime/instance"
|
|
5
|
+
|
|
6
|
+
module Hecks
|
|
7
|
+
module Adapters
|
|
8
|
+
# A BROWSER-HOSTED DOMAIN'S OWN DECLARED INTENT — not a second Memory
|
|
9
|
+
# wearing a different name. Ruby has no way to reach a real browser's
|
|
10
|
+
# `window.localStorage` at all (it is per-tab, per-origin, JS-only,
|
|
11
|
+
# unreachable over any network the way D1's own REST API is) — so
|
|
12
|
+
# this Ruby-side adapter is honestly a stand-in: in-process, ephemeral,
|
|
13
|
+
# mechanically identical to Memory. What earns it a name of its own
|
|
14
|
+
# is what it DECLARES, not what it happens to do in Ruby: `persisted_by
|
|
15
|
+
# "LocalStorage"` says "this domain expects real, durable, single-
|
|
16
|
+
# device storage the moment it's actually running where it's meant to
|
|
17
|
+
# run" — the same distinction Heki (real local durability) already
|
|
18
|
+
# draws against Memory (deliberately ephemeral, test/example-only),
|
|
19
|
+
# one adapter over. `bin/console`, the fuzzer, `spec/`, `bin/
|
|
20
|
+
# model_check` all get a domain that boots and behaves correctly
|
|
21
|
+
# against this adapter; only a real browser gets the real durability.
|
|
22
|
+
#
|
|
23
|
+
# THE REAL BROWSER HALF lives outside this file entirely: `rust/web`'s
|
|
24
|
+
# `dispatch(json)` (docs/implemented/decisions/0015) takes an optional
|
|
25
|
+
# `"seed"` (the exact `"instances"` shape it also answers with) plus
|
|
26
|
+
# `"steps"` — a host rehydrates from a prior snapshot and replays only
|
|
27
|
+
# the new command(s), rather than the whole history every call. A
|
|
28
|
+
# page bound to this adapter is expected to hold that snapshot in
|
|
29
|
+
# `window.localStorage` itself (get on load, set after every
|
|
30
|
+
# `dispatch`) — the seed/instances round trip IS the adapter, once
|
|
31
|
+
# you're in the one runtime that can actually reach the storage this
|
|
32
|
+
# name promises.
|
|
33
|
+
class LocalStorage
|
|
34
|
+
# TENANT-CAPABLE TRIVIALLY, same reasoning as Memory's own — a
|
|
35
|
+
# browser tab is exactly one origin, exactly one user; there is no
|
|
36
|
+
# second tenant this in-process Hash could ever confuse a first
|
|
37
|
+
# one with.
|
|
38
|
+
def self.tenant_capable? = true
|
|
39
|
+
def persistence_capabilities = [:atomic_put]
|
|
40
|
+
|
|
41
|
+
attr_reader :aggregate, :events
|
|
42
|
+
|
|
43
|
+
def initialize(aggregate:, settings: {}, root: nil)
|
|
44
|
+
@aggregate = aggregate
|
|
45
|
+
@records = {}
|
|
46
|
+
@events = []
|
|
47
|
+
@entries = []
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def find(id) = @records[id.to_s]
|
|
51
|
+
def count = @records.size
|
|
52
|
+
|
|
53
|
+
def all(order_by: nil, direction: :asc)
|
|
54
|
+
InMemoryOrdering.ordered(@records.values, aggregate: @aggregate, order_by: order_by, direction: direction)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# THE DECISION THE GUIDE ASKS FOR, MADE EXPLICITLY: no compiled
|
|
58
|
+
# dialect of its own, same as Heki/Memory — a personal-scale local
|
|
59
|
+
# store answering by walking `all` is correct on day one, and
|
|
60
|
+
# nothing about a browser tab's own data volume asks for pushdown.
|
|
61
|
+
def query(specification, args = {}, context: {})
|
|
62
|
+
Ports::Query::InMemory.execute(all, specification, args, registry: context[:registry])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def append(entry)
|
|
66
|
+
@entries << entry
|
|
67
|
+
entry
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def project(entry)
|
|
71
|
+
if entry.save?
|
|
72
|
+
@records[entry.id] = Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: entry.state.dup)
|
|
73
|
+
else
|
|
74
|
+
@records.delete(entry.id)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def save(instance)
|
|
79
|
+
entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
|
|
80
|
+
append(entry)
|
|
81
|
+
project(entry)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def atomic_put(entry, insert_only: false)
|
|
85
|
+
exists = @records.key?(entry.id.to_s)
|
|
86
|
+
return :conflicted if insert_only && exists
|
|
87
|
+
|
|
88
|
+
status = exists ? :replaced : :inserted
|
|
89
|
+
append(entry)
|
|
90
|
+
project(entry)
|
|
91
|
+
status
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def delete(id)
|
|
95
|
+
entry = Ports::Persistence::Entry.new(operation: "delete", id: id.to_s, state: nil)
|
|
96
|
+
append(entry)
|
|
97
|
+
project(entry)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def record_event(event) = @events << event
|
|
101
|
+
|
|
102
|
+
def entries = @entries.dup
|
|
103
|
+
|
|
104
|
+
def reset!
|
|
105
|
+
@records = {}
|
|
106
|
+
@events = []
|
|
107
|
+
@entries = []
|
|
108
|
+
self
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# NOT lineage_capable? — deliberately absent, the same trade Heki
|
|
112
|
+
# makes and states plainly (writing-an-adapter.md's own section on
|
|
113
|
+
# it): a domain bound here has no edge for its own shape to travel
|
|
114
|
+
# across if it ever changes; that must be hand-migrated, or the
|
|
115
|
+
# shape must not change. A browser-local personal store is exactly
|
|
116
|
+
# the small-adapter case that guide names as a fine place to make
|
|
117
|
+
# that trade.
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -5,6 +5,13 @@ require_relative "../../runtime/instance"
|
|
|
5
5
|
|
|
6
6
|
module Hecks
|
|
7
7
|
module Adapters
|
|
8
|
+
# The default, no-external-dependency persistence/query adapter — a
|
|
9
|
+
# plain in-process Hash of `Runtime::Instance` records per aggregate,
|
|
10
|
+
# with an append-only `@entries`/`@events` log alongside it. Every
|
|
11
|
+
# behaviors suite must resolve to this one (see
|
|
12
|
+
# Behaviors::Expectations#guard_memory_only!) precisely because a fresh
|
|
13
|
+
# instance really is a fresh store, with nothing shared across tests or
|
|
14
|
+
# tenants.
|
|
8
15
|
class Memory
|
|
9
16
|
# TENANT-CAPABLE TRIVIALLY — see Runtime::TenantCheck's own header
|
|
10
17
|
# for the full reasoning. `@records` is a plain instance variable;
|
|
@@ -15,13 +22,14 @@ module Hecks
|
|
|
15
22
|
def self.tenant_capable? = true
|
|
16
23
|
def persistence_capabilities = [:atomic_put]
|
|
17
24
|
|
|
18
|
-
attr_reader :aggregate
|
|
25
|
+
attr_reader :aggregate, :events
|
|
19
26
|
|
|
20
27
|
def initialize(aggregate:, settings: {}, root: nil)
|
|
21
28
|
@aggregate = aggregate
|
|
22
29
|
@records = {}
|
|
23
30
|
@events = []
|
|
24
31
|
@entries = []
|
|
32
|
+
@outbox = []
|
|
25
33
|
end
|
|
26
34
|
|
|
27
35
|
def find(id) = @records[id.to_s]
|
|
@@ -75,8 +83,6 @@ module Hecks
|
|
|
75
83
|
|
|
76
84
|
def record_event(event) = @events << event
|
|
77
85
|
|
|
78
|
-
def events = @events
|
|
79
|
-
|
|
80
86
|
def entries = @entries.dup
|
|
81
87
|
|
|
82
88
|
# Every other driven adapter (Postgres/PostgresEra/Sqlite/D1)
|
|
@@ -96,8 +102,47 @@ module Hecks
|
|
|
96
102
|
@records = {}
|
|
97
103
|
@events = []
|
|
98
104
|
@entries = []
|
|
105
|
+
@outbox = []
|
|
99
106
|
self
|
|
100
107
|
end
|
|
108
|
+
|
|
109
|
+
# No rollback here — a Hash has no transaction to join. Memory
|
|
110
|
+
# implements `transaction` so `Interpreting#run_dispatch_order` has
|
|
111
|
+
# one shape to call, and the outbox so a spec can watch rows move
|
|
112
|
+
# pending → claimed → delivered without a database (the same
|
|
113
|
+
# reason Memory records `events`). See `Runtime::Outbox`.
|
|
114
|
+
def transaction = yield
|
|
115
|
+
|
|
116
|
+
def outbox_enqueue(rows)
|
|
117
|
+
rows.filter_map do |row|
|
|
118
|
+
next nil if @outbox.any? { |held| held.delivery_id == row.delivery_id }
|
|
119
|
+
|
|
120
|
+
row.id = @outbox.size + 1
|
|
121
|
+
@outbox << row
|
|
122
|
+
row
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def outbox_claim(id) # rubocop:disable Naming/PredicateMethod
|
|
127
|
+
row = @outbox.find { |held| held.id == id }
|
|
128
|
+
return false unless row&.pending?
|
|
129
|
+
|
|
130
|
+
row.status = "claimed"
|
|
131
|
+
row.attempts += 1
|
|
132
|
+
true
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def outbox_settle(id, status:, error: nil) # rubocop:disable Naming/PredicateMethod
|
|
136
|
+
row = @outbox.find { |held| held.id == id } or return false
|
|
137
|
+
row.status = status.to_s
|
|
138
|
+
row.error = error
|
|
139
|
+
true
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def outbox_rows(status: nil)
|
|
143
|
+
rows = status ? @outbox.select { |row| row.status == status.to_s } : @outbox
|
|
144
|
+
rows.map(&:dup)
|
|
145
|
+
end
|
|
101
146
|
end
|
|
102
147
|
end
|
|
103
148
|
end
|
|
@@ -20,7 +20,9 @@ module Hecks
|
|
|
20
20
|
{ name: attribute.name, attribute: attribute, sql_type: sql_type(attribute) }
|
|
21
21
|
end
|
|
22
22
|
lifecycle = @aggregate.lifecycle
|
|
23
|
-
fields << { name: lifecycle.field, attribute: nil, sql_type: "text" } if lifecycle &&
|
|
23
|
+
fields << { name: lifecycle.field, attribute: nil, sql_type: "text" } if lifecycle && fields.none? do |field|
|
|
24
|
+
field[:name] == lifecycle.field
|
|
25
|
+
end
|
|
24
26
|
# `projects` FIELDS (S12, ADR 0025) ARE A LOCAL COLUMN TOO — see
|
|
25
27
|
# Sqlite::Codec#persisted_fields' own comment; identical reasoning,
|
|
26
28
|
# `text` to match this file's own lowercase SQL type spelling.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "../../../runtime/outbox"
|
|
3
|
+
|
|
4
|
+
module Hecks
|
|
5
|
+
module Adapters
|
|
6
|
+
# THE OUTBOX, POSTGRES-SHAPED — shared verbatim by `Postgres` and the
|
|
7
|
+
# era plugin's `PostgresEra`, the same way their `events` and
|
|
8
|
+
# `hecks_saga_instances` DDL is copied between them: nothing here is
|
|
9
|
+
# lineage-specific. Needs `@db` (a `PG::Connection`) and `table`
|
|
10
|
+
# (the aggregate's storage name) from the including class. See
|
|
11
|
+
# `Runtime::Outbox` for what the four verbs mean, and Sqlite's copy
|
|
12
|
+
# for the SQL idioms (`ON CONFLICT DO NOTHING` = idempotent enqueue,
|
|
13
|
+
# `WHERE status = 'pending'` = the compare-and-set claim).
|
|
14
|
+
module PostgresOutbox
|
|
15
|
+
# RE-ENTRANT — `Interpreting#run_dispatch_order` opens one
|
|
16
|
+
# transaction around save+emit+outbox and the adapter's own
|
|
17
|
+
# `append`/`atomic_put`/`delete` each open theirs; PG refuses
|
|
18
|
+
# BEGIN inside BEGIN, so an inner call joins the open one.
|
|
19
|
+
def transaction(&)
|
|
20
|
+
return yield unless @db.transaction_status == PG::PQTRANS_IDLE
|
|
21
|
+
|
|
22
|
+
@db.transaction(&)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def outbox_enqueue(rows)
|
|
26
|
+
rows.filter_map do |row|
|
|
27
|
+
result = @db.exec_params(
|
|
28
|
+
"INSERT INTO hecks_outbox (delivery_id, event_uid, aggregate, domain, kind, consumer, event, status, attempts) " \
|
|
29
|
+
"VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', 0) ON CONFLICT (delivery_id) DO NOTHING RETURNING id",
|
|
30
|
+
[row.delivery_id, row.event_uid, row.aggregate, row.domain, row.kind, row.consumer, JSON.generate(row.event)]
|
|
31
|
+
)
|
|
32
|
+
next nil if result.ntuples.zero?
|
|
33
|
+
|
|
34
|
+
row.id = result[0]["id"].to_i
|
|
35
|
+
row.status = "pending"
|
|
36
|
+
row
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def outbox_claim(id) # rubocop:disable Naming/PredicateMethod
|
|
41
|
+
@db.exec_params(
|
|
42
|
+
"UPDATE hecks_outbox SET status = 'claimed', attempts = attempts + 1, claimed_at = now() " \
|
|
43
|
+
"WHERE id = $1 AND status = 'pending'",
|
|
44
|
+
[id]
|
|
45
|
+
).cmd_tuples == 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def outbox_settle(id, status:, error: nil) # rubocop:disable Naming/PredicateMethod
|
|
49
|
+
@db.exec_params(
|
|
50
|
+
"UPDATE hecks_outbox SET status = $2, error = $3, settled_at = now() WHERE id = $1",
|
|
51
|
+
[id, status.to_s, error]
|
|
52
|
+
).cmd_tuples == 1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def outbox_rows(status: nil)
|
|
56
|
+
sql = "SELECT * FROM hecks_outbox WHERE aggregate = $1"
|
|
57
|
+
binds = [table]
|
|
58
|
+
if status
|
|
59
|
+
sql << " AND status = $2"
|
|
60
|
+
binds << status.to_s
|
|
61
|
+
end
|
|
62
|
+
@db.exec_params("#{sql} ORDER BY id", binds).map do |row|
|
|
63
|
+
Runtime::Outbox::Row.new(
|
|
64
|
+
id: row["id"].to_i, delivery_id: row["delivery_id"], event_uid: row["event_uid"], aggregate: row["aggregate"],
|
|
65
|
+
domain: row["domain"], kind: row["kind"], consumer: row["consumer"],
|
|
66
|
+
event: JSON.parse(row["event"], symbolize_names: true), status: row["status"],
|
|
67
|
+
attempts: row["attempts"].to_i, error: row["error"]
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def create_outbox_table!
|
|
75
|
+
@db.exec(<<~SQL)
|
|
76
|
+
CREATE TABLE IF NOT EXISTS hecks_outbox (
|
|
77
|
+
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
78
|
+
delivery_id text NOT NULL UNIQUE,
|
|
79
|
+
event_uid text NOT NULL,
|
|
80
|
+
aggregate text NOT NULL,
|
|
81
|
+
domain text NOT NULL,
|
|
82
|
+
kind text NOT NULL,
|
|
83
|
+
consumer text NOT NULL,
|
|
84
|
+
event jsonb NOT NULL,
|
|
85
|
+
status text NOT NULL DEFAULT 'pending',
|
|
86
|
+
attempts integer NOT NULL DEFAULT 0,
|
|
87
|
+
error text,
|
|
88
|
+
enqueued_at timestamptz NOT NULL DEFAULT now(),
|
|
89
|
+
claimed_at timestamptz,
|
|
90
|
+
settled_at timestamptz
|
|
91
|
+
)
|
|
92
|
+
SQL
|
|
93
|
+
@db.exec("CREATE INDEX IF NOT EXISTS idx_hecks_outbox_status ON hecks_outbox(aggregate, status)")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -17,7 +17,7 @@ module Hecks
|
|
|
17
17
|
def create_aggregate_table!
|
|
18
18
|
columns = persisted_fields.map { |field| "#{quote_ident(field[:name])} #{field[:sql_type]}" }
|
|
19
19
|
@db.exec(
|
|
20
|
-
"CREATE TABLE IF NOT EXISTS #{quoted_table} (id text PRIMARY KEY#{columns.empty?
|
|
20
|
+
"CREATE TABLE IF NOT EXISTS #{quoted_table} (id text PRIMARY KEY#{', ' unless columns.empty?}#{columns.join(', ')})"
|
|
21
21
|
)
|
|
22
22
|
# SELF-HEALING, SAME IDIOM AS `ensure_indexes!` BELOW —
|
|
23
23
|
# `CREATE TABLE IF NOT EXISTS` above does NOT retroactively add a
|
|
@@ -84,7 +84,8 @@ module Hecks
|
|
|
84
84
|
# — the same idiom `rust/host/src/journal.rs`'s own
|
|
85
85
|
# `sagas_backfilled` column addition already uses, for the
|
|
86
86
|
# identical reason.
|
|
87
|
-
@db.exec("ALTER TABLE hecks_saga_instances ADD COLUMN IF NOT EXISTS completed_compensations jsonb
|
|
87
|
+
@db.exec("ALTER TABLE hecks_saga_instances ADD COLUMN IF NOT EXISTS completed_compensations jsonb " \
|
|
88
|
+
"NOT NULL DEFAULT '[]'::jsonb")
|
|
88
89
|
end
|
|
89
90
|
|
|
90
91
|
def sql_type(attr)
|