hecks 1.0.2 → 1.2.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 +100 -0
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +22 -2
- data/lib/hecks/adapters/driven/postgres.rb +45 -31
- 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 +22 -3
- 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 +15 -4
- 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/read_model.rb +53 -8
- 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 +29 -477
- 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 +142 -19
- 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 +51 -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 +77 -18
- 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/glossary.rb +374 -0
- 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/projections.rb +1 -0
- data/lib/hecks/projector/cli_projector.rb +78 -42
- data/lib/hecks/projector/docs_projector.rb +21 -12
- data/lib/hecks/projector/exporter.rb +34 -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/limit_spec.rb +9 -2
- data/lib/hecks/query_specification/common/null_policy.rb +6 -2
- data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
- data/lib/hecks/query_specification/common/options.rb +17 -5
- data/lib/hecks/query_specification/common/order_by.rb +9 -2
- data/lib/hecks/query_specification/common/where_clause.rb +15 -2
- 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 +45 -9
- 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 +31 -7
- 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 +271 -49
- 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 +16 -2
|
@@ -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
|
|
@@ -28,6 +28,25 @@ module Hecks
|
|
|
28
28
|
# `persisted_fields` (Codec), so it never appears in `decode`'s
|
|
29
29
|
# domain-state hash or `Instance#to_h`.
|
|
30
30
|
@db.exec("ALTER TABLE #{quoted_table} ADD COLUMN IF NOT EXISTS hecks_version bigint NOT NULL DEFAULT 1")
|
|
31
|
+
# SAME SELF-HEALING SHAPE, FOR DOMAIN ATTRIBUTES THEMSELVES —
|
|
32
|
+
# `hecks_version` above only heals the adapter's own bookkeeping
|
|
33
|
+
# column; a bluebook attribute added (or, via `translations/`,
|
|
34
|
+
# renamed) after this table already exists is not bookkeeping,
|
|
35
|
+
# but the identical gap applies: `CREATE TABLE IF NOT EXISTS`
|
|
36
|
+
# is a no-op against the existing table, so without this a new
|
|
37
|
+
# attribute boots clean and then dies `PG::UndefinedColumn` on
|
|
38
|
+
# the first `project` — discovered chaos-testing a live rename
|
|
39
|
+
# against this adapter (no era, no translation prompt; those
|
|
40
|
+
# live in `PostgresEra` — this is the plain adapter's own,
|
|
41
|
+
# simpler contract: "the table always has every column the
|
|
42
|
+
# bluebook currently declares"). No `NOT NULL`, no `DEFAULT` —
|
|
43
|
+
# existing rows get SQL NULL for a column they never had a
|
|
44
|
+
# value for, exactly what a fresh row would get for an unset
|
|
45
|
+
# optional attribute (`encode_field`'s own nil handling).
|
|
46
|
+
persisted_fields.each do |field|
|
|
47
|
+
@db.exec("ALTER TABLE #{quoted_table} ADD COLUMN IF NOT EXISTS " \
|
|
48
|
+
"#{quote_ident(field[:name])} #{field[:sql_type]}")
|
|
49
|
+
end
|
|
31
50
|
# RIGHT HERE, NOT A SEPARATE STEP IN `Postgres#initialize` —
|
|
32
51
|
# same idiom Sqlite::SchemaBuilder's own `create_aggregate_table!`
|
|
33
52
|
# uses: index creation runs unconditionally, right after the
|
|
@@ -84,7 +103,8 @@ module Hecks
|
|
|
84
103
|
# — the same idiom `rust/host/src/journal.rs`'s own
|
|
85
104
|
# `sagas_backfilled` column addition already uses, for the
|
|
86
105
|
# identical reason.
|
|
87
|
-
@db.exec("ALTER TABLE hecks_saga_instances ADD COLUMN IF NOT EXISTS completed_compensations jsonb
|
|
106
|
+
@db.exec("ALTER TABLE hecks_saga_instances ADD COLUMN IF NOT EXISTS completed_compensations jsonb " \
|
|
107
|
+
"NOT NULL DEFAULT '[]'::jsonb")
|
|
88
108
|
end
|
|
89
109
|
|
|
90
110
|
def sql_type(attr)
|
|
@@ -9,6 +9,8 @@ require_relative "../../query_specification/common/order_by"
|
|
|
9
9
|
require_relative "../../query_specification/field_path"
|
|
10
10
|
require_relative "../../runtime/errors"
|
|
11
11
|
require_relative "../../runtime/event"
|
|
12
|
+
require_relative "postgres/outbox"
|
|
13
|
+
require_relative "postgres/reconnect"
|
|
12
14
|
require_relative "../../runtime/instance"
|
|
13
15
|
|
|
14
16
|
module Hecks
|
|
@@ -44,6 +46,8 @@ module Hecks
|
|
|
44
46
|
include SqlQueryBuilder
|
|
45
47
|
include SchemaBuilder
|
|
46
48
|
include Codec
|
|
49
|
+
include PostgresOutbox
|
|
50
|
+
include PostgresReconnect
|
|
47
51
|
|
|
48
52
|
SQL_TYPES = { "Integer" => "bigint", "Float" => "double precision" }.freeze
|
|
49
53
|
|
|
@@ -84,13 +88,14 @@ module Hecks
|
|
|
84
88
|
# boot after the first, not news.
|
|
85
89
|
connection.exec("SET client_min_messages = warning")
|
|
86
90
|
connection
|
|
87
|
-
rescue PG::Error =>
|
|
91
|
+
rescue PG::Error => e
|
|
88
92
|
raise Runtime::WiringError,
|
|
89
|
-
"cannot bind Postgres at #{declared} for #{name}: #{
|
|
93
|
+
"cannot bind Postgres at #{declared} for #{name}: #{e.message.strip}"
|
|
90
94
|
end
|
|
91
95
|
|
|
92
96
|
def initialize(aggregate:, settings: {}, root: nil)
|
|
93
97
|
@aggregate = aggregate
|
|
98
|
+
@settings = settings
|
|
94
99
|
@db = self.class.connect_for(aggregate.name, settings)
|
|
95
100
|
# THE OPTIONAL saga-persistence capability's own scoping column
|
|
96
101
|
# (§2/§4) — falls back to the aggregate's own storage name for a
|
|
@@ -110,12 +115,13 @@ module Hecks
|
|
|
110
115
|
create_entry_table!
|
|
111
116
|
create_event_table!
|
|
112
117
|
create_saga_table!
|
|
118
|
+
create_outbox_table!
|
|
113
119
|
end
|
|
114
120
|
|
|
115
121
|
def table = @aggregate.storage_name
|
|
116
122
|
|
|
117
123
|
def find(id)
|
|
118
|
-
result =
|
|
124
|
+
result = pg_exec_params("SELECT * FROM #{quoted_table} WHERE id = $1", [id.to_s])
|
|
119
125
|
return nil if result.ntuples.zero?
|
|
120
126
|
|
|
121
127
|
instance_from_row(result[0])
|
|
@@ -128,19 +134,22 @@ module Hecks
|
|
|
128
134
|
order_sql = "ORDER BY id"
|
|
129
135
|
if order_by
|
|
130
136
|
name = order_by.to_s.split(".").first
|
|
131
|
-
|
|
137
|
+
unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name)
|
|
138
|
+
raise Runtime::WiringError,
|
|
139
|
+
"#{@aggregate.name} has no attribute #{order_by.inspect} to order by"
|
|
140
|
+
end
|
|
132
141
|
|
|
133
142
|
spec = QuerySpecification::Common::OrderBy.new(field: order_by, direction: direction)
|
|
134
143
|
order_sql = "ORDER BY #{order_clause(spec, nil)}"
|
|
135
144
|
end
|
|
136
145
|
|
|
137
|
-
|
|
146
|
+
pg_exec("SELECT * FROM #{quoted_table} #{order_sql}").map { |row| instance_from_row(row) }
|
|
138
147
|
end
|
|
139
148
|
|
|
140
|
-
def count =
|
|
149
|
+
def count = pg_exec("SELECT COUNT(*) FROM #{quoted_table}")[0]["count"].to_i
|
|
141
150
|
|
|
142
151
|
def append(entry)
|
|
143
|
-
|
|
152
|
+
pg_exec_params(
|
|
144
153
|
"INSERT INTO #{quoted_entry_table} (aggregate_id, operation, state, mirrors) VALUES ($1, $2, $3, $4)",
|
|
145
154
|
# `mirrors` (unlike `state`) is a NULLABLE column — an absent
|
|
146
155
|
# mirrors hash must bind a real SQL NULL, not the four-character
|
|
@@ -169,8 +178,10 @@ module Hecks
|
|
|
169
178
|
# zero rows back means the conflict branch's WHERE excluded the row
|
|
170
179
|
# entirely — the version had already moved — so `nil` is returned
|
|
171
180
|
# for the caller (`AppendOnly#save`) to treat as "stale, no-op".
|
|
181
|
+
# rubocop:disable Metrics/AbcSize -- the CAS/plain upsert split is one
|
|
182
|
+
# protocol; splitting it would hide the version handshake.
|
|
172
183
|
def project(entry, expected_version: nil)
|
|
173
|
-
return
|
|
184
|
+
return pg_exec_params("DELETE FROM #{quoted_table} WHERE id = $1", [entry.id]) if entry.delete?
|
|
174
185
|
|
|
175
186
|
instance = Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: entry.state)
|
|
176
187
|
columns = (["id"] + persisted_fields.map { |field| field[:name].to_s } + ["hecks_version"])
|
|
@@ -187,15 +198,16 @@ module Hecks
|
|
|
187
198
|
end
|
|
188
199
|
sql += " RETURNING hecks_version"
|
|
189
200
|
|
|
190
|
-
result =
|
|
201
|
+
result = pg_exec_params(sql, values)
|
|
191
202
|
return nil if result.ntuples.zero?
|
|
192
203
|
|
|
193
204
|
instance.version = result[0]["hecks_version"].to_i
|
|
194
205
|
instance
|
|
195
206
|
end
|
|
207
|
+
# rubocop:enable Metrics/AbcSize
|
|
196
208
|
|
|
197
209
|
def entries
|
|
198
|
-
|
|
210
|
+
pg_exec("SELECT aggregate_id, operation, state, mirrors FROM #{quoted_entry_table} ORDER BY sequence").map do |row|
|
|
199
211
|
state = JSON.parse(row["state"])
|
|
200
212
|
Ports::Persistence::Entry.new(
|
|
201
213
|
operation: row["operation"] || "save",
|
|
@@ -207,8 +219,8 @@ module Hecks
|
|
|
207
219
|
end
|
|
208
220
|
|
|
209
221
|
def reset!
|
|
210
|
-
|
|
211
|
-
|
|
222
|
+
pg_exec("DELETE FROM #{quoted_table}")
|
|
223
|
+
pg_exec("DELETE FROM #{quoted_entry_table}")
|
|
212
224
|
self
|
|
213
225
|
end
|
|
214
226
|
|
|
@@ -221,24 +233,21 @@ module Hecks
|
|
|
221
233
|
# transaction lives here, the one caller that runs both together.
|
|
222
234
|
def save(instance)
|
|
223
235
|
entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
|
|
224
|
-
|
|
236
|
+
transaction do
|
|
237
|
+
append(entry)
|
|
238
|
+
project(entry)
|
|
239
|
+
end
|
|
225
240
|
end
|
|
226
241
|
|
|
227
|
-
# Classification, journal append and snapshot replacement are one
|
|
228
|
-
# Postgres transaction. A row lock cannot serialize two first writers —
|
|
229
|
-
# there is no row to lock yet — so a transaction-scoped advisory lock on
|
|
230
|
-
# schema/table + aggregate id owns that missing-row race as well. Once a
|
|
231
|
-
# writer acquires it, the preceding writer has committed and status can
|
|
232
|
-
# be read from the materialized table without a runtime-side find.
|
|
233
242
|
def atomic_put(entry, insert_only: false)
|
|
234
243
|
status = nil
|
|
235
|
-
|
|
236
|
-
|
|
244
|
+
transaction do
|
|
245
|
+
pg_exec_params(
|
|
237
246
|
"SELECT pg_advisory_xact_lock(" \
|
|
238
247
|
"hashtext(current_schema() || ':' || $1), hashtext($2))",
|
|
239
248
|
[table, entry.id.to_s]
|
|
240
249
|
)
|
|
241
|
-
exists =
|
|
250
|
+
exists = !pg_exec_params(
|
|
242
251
|
"SELECT 1 FROM #{quoted_table} WHERE id = $1",
|
|
243
252
|
[entry.id.to_s]
|
|
244
253
|
).ntuples.zero?
|
|
@@ -255,19 +264,22 @@ module Hecks
|
|
|
255
264
|
|
|
256
265
|
def delete(id)
|
|
257
266
|
entry = Ports::Persistence::Entry.new(operation: "delete", id: id.to_s, state: nil)
|
|
258
|
-
|
|
267
|
+
transaction do
|
|
268
|
+
append(entry)
|
|
269
|
+
project(entry)
|
|
270
|
+
end
|
|
259
271
|
true
|
|
260
272
|
end
|
|
261
273
|
|
|
262
274
|
def record_event(event)
|
|
263
|
-
|
|
275
|
+
pg_exec_params(
|
|
264
276
|
"INSERT INTO events (name, aggregate, aggregate_id, payload, occurred_at) VALUES ($1, $2, $3, $4, $5)",
|
|
265
277
|
[event.name, event.aggregate, event.id.to_s, JSON.generate(event.payload), event.occurred_at]
|
|
266
278
|
)
|
|
267
279
|
end
|
|
268
280
|
|
|
269
281
|
def events
|
|
270
|
-
|
|
282
|
+
pg_exec("SELECT * FROM events ORDER BY id").map do |row|
|
|
271
283
|
Runtime::Event.new(
|
|
272
284
|
name: row["name"],
|
|
273
285
|
aggregate: row["aggregate"],
|
|
@@ -282,18 +294,19 @@ module Hecks
|
|
|
282
294
|
# shape as PostgresEra's own (postgres_era.rb), not lineage-
|
|
283
295
|
# specific, copied verbatim.
|
|
284
296
|
def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
|
|
285
|
-
|
|
297
|
+
pg_exec_params(
|
|
286
298
|
"INSERT INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, completed_compensations) " \
|
|
287
299
|
"VALUES ($1, $2, $3, $4, $5, $6) " \
|
|
288
300
|
"ON CONFLICT (domain, process_manager, correlation) DO UPDATE " \
|
|
289
301
|
"SET state = EXCLUDED.state, memory = EXCLUDED.memory, " \
|
|
290
302
|
"completed_compensations = EXCLUDED.completed_compensations, updated_at = now()",
|
|
291
|
-
[@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory),
|
|
303
|
+
[@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory),
|
|
304
|
+
JSON.generate(completed_compensations)]
|
|
292
305
|
)
|
|
293
306
|
end
|
|
294
307
|
|
|
295
308
|
def delete_saga(process_manager:, correlation:)
|
|
296
|
-
|
|
309
|
+
pg_exec_params(
|
|
297
310
|
"DELETE FROM hecks_saga_instances WHERE domain = $1 AND process_manager = $2 AND correlation = $3",
|
|
298
311
|
[@domain, process_manager.to_s, correlation.to_s]
|
|
299
312
|
)
|
|
@@ -302,8 +315,9 @@ module Hecks
|
|
|
302
315
|
def each_saga
|
|
303
316
|
return enum_for(:each_saga) unless block_given?
|
|
304
317
|
|
|
305
|
-
|
|
306
|
-
"SELECT process_manager, correlation, state, memory, completed_compensations
|
|
318
|
+
pg_exec_params(
|
|
319
|
+
"SELECT process_manager, correlation, state, memory, completed_compensations " \
|
|
320
|
+
"FROM hecks_saga_instances WHERE domain = $1",
|
|
307
321
|
[@domain]
|
|
308
322
|
).each do |row|
|
|
309
323
|
yield row["process_manager"], row["correlation"], row["state"],
|
|
@@ -370,7 +384,7 @@ module Hecks
|
|
|
370
384
|
end
|
|
371
385
|
|
|
372
386
|
def execute_query(sql, binds)
|
|
373
|
-
|
|
387
|
+
pg_exec_params(sql, binds).map { |row| instance_from_row(row) }
|
|
374
388
|
end
|
|
375
389
|
|
|
376
390
|
# Stamps `.version` (adapter bookkeeping, never domain state — see
|
|
@@ -5,7 +5,18 @@ module Hecks
|
|
|
5
5
|
module Adapters
|
|
6
6
|
class NotExtractable < StandardError; end
|
|
7
7
|
|
|
8
|
+
# Extracts a `given`/`ensures`/invariant block's own source text back out
|
|
9
|
+
# of the `.rb` file it was defined in (via `block.source_location`),
|
|
10
|
+
# parses it with the `prism` gem, and canonicalises it — how a rule's
|
|
11
|
+
# predicate becomes readable, comparable text (era diffing, docs) rather
|
|
12
|
+
# than an opaque compiled Proc. Per-process `TREES` cache keyed by file
|
|
13
|
+
# path; `forget`/`forget_all` exist for a caller that reloads an edited
|
|
14
|
+
# file in-process (see their own comment).
|
|
8
15
|
module Prism
|
|
16
|
+
# NOT frozen — a real cache, keyed by file path and mutated by
|
|
17
|
+
# #tree_for below (`TREES[file] ||= ...`) and #forget/#forget_all.
|
|
18
|
+
# False positive for Style/MutableConstant.
|
|
19
|
+
# rubocop:disable-next Style/MutableConstant
|
|
9
20
|
TREES = {}
|
|
10
21
|
|
|
11
22
|
module_function
|
|
@@ -34,24 +34,11 @@ module Hecks
|
|
|
34
34
|
}.freeze
|
|
35
35
|
|
|
36
36
|
def query(declared, args = {}, context: {})
|
|
37
|
-
sql =
|
|
38
|
-
clauses = []
|
|
37
|
+
sql = "SELECT #{select_list} FROM #{from_relation}"
|
|
39
38
|
binds = []
|
|
40
|
-
declared
|
|
41
|
-
value = query_value(clause.value, args)
|
|
42
|
-
expression = query_expression(clause.field, value: value)
|
|
43
|
-
if (null_predicate = QuerySpecification::Common::NullPolicy.sql_predicate(expression, clause.op, value))
|
|
44
|
-
clauses << null_predicate.first
|
|
45
|
-
next
|
|
46
|
-
end
|
|
47
|
-
clauses << where_clause(clause.op.to_s, expression, value, binds, field: clause.field)
|
|
48
|
-
end
|
|
39
|
+
clauses = where_clauses(declared, args, binds)
|
|
49
40
|
sql << " WHERE #{clauses.join(' AND ')}" unless clauses.empty?
|
|
50
|
-
sql <<
|
|
51
|
-
" ORDER BY #{order_clause(declared.order_by, declared.null_semantics)}"
|
|
52
|
-
else
|
|
53
|
-
" ORDER BY id"
|
|
54
|
-
end
|
|
41
|
+
sql << order_by_sql(declared)
|
|
55
42
|
sql << " LIMIT #{placeholder(binds, query_value(declared.limit.value, args).to_i)}" if declared.limit
|
|
56
43
|
# An offset with no declared limit — SQLite refuses a bare OFFSET
|
|
57
44
|
# outright (LIMIT -1 is its unbounded idiom) while Postgres accepts
|
|
@@ -65,10 +52,34 @@ module Hecks
|
|
|
65
52
|
|
|
66
53
|
private
|
|
67
54
|
|
|
68
|
-
|
|
69
|
-
|
|
55
|
+
# Every declared `where` clause, compiled in declaration order — a
|
|
56
|
+
# null-policy predicate short-circuits the ordinary comparator path
|
|
57
|
+
# per clause, same as it did inline. `binds` is populated in place
|
|
58
|
+
# (the same array `query` goes on to push LIMIT/OFFSET placeholders
|
|
59
|
+
# into afterward), so pulling this loop out changes nothing about
|
|
60
|
+
# bind-parameter order.
|
|
61
|
+
def where_clauses(declared, args, binds)
|
|
62
|
+
declared.wheres.each_with_object([]) do |clause, clauses|
|
|
63
|
+
value = query_value(clause.value, args)
|
|
64
|
+
expression = query_expression(clause.field, value: value)
|
|
65
|
+
if (null_predicate = QuerySpecification::Common::NullPolicy.sql_predicate(expression, clause.op, value))
|
|
66
|
+
clauses << null_predicate.first
|
|
67
|
+
next
|
|
68
|
+
end
|
|
69
|
+
clauses << where_clause(clause.op.to_s, expression, value, binds, field: clause.field)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def order_by_sql(declared)
|
|
74
|
+
return " ORDER BY #{order_clause(declared.order_by, declared.null_semantics)}" if declared.order_by
|
|
75
|
+
|
|
76
|
+
" ORDER BY id"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def where_clause(oper, expression, value, binds, field: nil)
|
|
80
|
+
case oper
|
|
70
81
|
when "eq", "ne", "gt", "gte", "lt", "lte"
|
|
71
|
-
"#{comparable_expression(expression, value)} #{COMPARATORS.fetch(
|
|
82
|
+
"#{comparable_expression(expression, value)} #{COMPARATORS.fetch(oper)} #{placeholder(binds, value)}"
|
|
72
83
|
when "contains"
|
|
73
84
|
member = field && list_member(field)
|
|
74
85
|
if member
|
|
@@ -97,7 +108,7 @@ module Hecks
|
|
|
97
108
|
# which Postgres overrides to cast numerics.
|
|
98
109
|
"CAST(#{expression} AS TEXT) IN (#{members.map { |member| placeholder(binds, member) }.join(', ')})"
|
|
99
110
|
else
|
|
100
|
-
raise ArgumentError, "#{dialect_name} query adapter does not support #{
|
|
111
|
+
raise ArgumentError, "#{dialect_name} query adapter does not support #{oper.inspect}"
|
|
101
112
|
end
|
|
102
113
|
end
|
|
103
114
|
|
|
@@ -137,6 +148,16 @@ module Hecks
|
|
|
137
148
|
# path that can never match. Measured, not assumed: a `where` on a
|
|
138
149
|
# has_one/belongs_to/reference_to field returned zero rows against
|
|
139
150
|
# real data until that exclusion was removed.
|
|
151
|
+
# `member` resolves through two ORDERED fallback tiers — the bound
|
|
152
|
+
# value's own numeric field first, then the declared value object's
|
|
153
|
+
# numeric-or-sole attribute — each documented above as fixing a
|
|
154
|
+
# real, measured bug (a reference field matching nothing, a
|
|
155
|
+
# single-attribute VO silently falling to the wrong convention).
|
|
156
|
+
# Splitting the tiers apart would separate two writes to the same
|
|
157
|
+
# `member` local across method boundaries, hiding the fallback
|
|
158
|
+
# order that makes them correct together.
|
|
159
|
+
# rubocop:disable-next Metrics/CyclomaticComplexity
|
|
160
|
+
# rubocop:disable-next Metrics/PerceivedComplexity
|
|
140
161
|
def query_expression(field, value: nil)
|
|
141
162
|
name, *path = field.to_s.split(".")
|
|
142
163
|
attribute = @aggregate.attribute(name)
|
|
@@ -147,6 +168,10 @@ module Hecks
|
|
|
147
168
|
member = if path.empty? && value
|
|
148
169
|
hash = value.is_a?(Runtime::Value) ? value.to_h : value
|
|
149
170
|
numeric = hash.is_a?(Hash) && hash.find { |_key, item| item.is_a?(Numeric) }
|
|
171
|
+
# NOT &.-able: `numeric` can be `false` (hash.is_a?(Hash) came
|
|
172
|
+
# back false) as well as nil (.find came back empty) — `&.`
|
|
173
|
+
# only guards nil, so `false.first` raises. False positive.
|
|
174
|
+
# rubocop:disable-next Style/SafeNavigation
|
|
150
175
|
numeric ? numeric.first : nil
|
|
151
176
|
end
|
|
152
177
|
member ||= if path.empty? && attribute && value_object?(attribute)
|
|
@@ -13,7 +13,9 @@ module Hecks
|
|
|
13
13
|
{ name: attribute.name, attribute: attribute, sql_type: sql_type(attribute) }
|
|
14
14
|
end
|
|
15
15
|
lifecycle = @aggregate.lifecycle
|
|
16
|
-
fields << { name: lifecycle.field, attribute: nil, sql_type: "TEXT" } if lifecycle &&
|
|
16
|
+
fields << { name: lifecycle.field, attribute: nil, sql_type: "TEXT" } if lifecycle && fields.none? do |field|
|
|
17
|
+
field[:name] == lifecycle.field
|
|
18
|
+
end
|
|
17
19
|
# `projects` FIELDS (S12, ADR 0025) ARE A LOCAL COLUMN TOO —
|
|
18
20
|
# `CommandInterpreter#seed_projected_fields`/`RebuildSweep`
|
|
19
21
|
# both write one straight into `Instance#state` the same as
|
|
@@ -67,7 +67,10 @@ module Hecks
|
|
|
67
67
|
# reads it — not the identity unwrap, which is gone : an identity is
|
|
68
68
|
# declared as a path and followed.
|
|
69
69
|
reference_id = args.fetch(model.reference_name).to_s
|
|
70
|
-
|
|
70
|
+
# Plural (ADR 0055) — `on:` lets `where`/`order_by`/`limit`/`offset`
|
|
71
|
+
# each name a different many-side head, so more than one can be
|
|
72
|
+
# eligible in the same read model now.
|
|
73
|
+
eligible = model.filtered_head_names
|
|
71
74
|
|
|
72
75
|
# ROOT FIRST, ALWAYS — see this method's own header. Mirrors
|
|
73
76
|
# `ReadModelInterpreter#project`'s identical partition, for the
|
|
@@ -87,7 +90,7 @@ module Hecks
|
|
|
87
90
|
else
|
|
88
91
|
select_related(aggregate, projected)
|
|
89
92
|
end
|
|
90
|
-
rows = Ports::Query::InMemory.execute(rows, model, args) if head[:as]
|
|
93
|
+
rows = Ports::Query::InMemory.execute(rows, model.options_for(head[:as]), args) if eligible.include?(head[:as])
|
|
91
94
|
projected << { aggregate: head[:aggregate], rows: rows }
|
|
92
95
|
reports[head[:as]] = if head[:many]
|
|
93
96
|
rows.map { |row| Runtime::Value.materialize(row.to_h) }
|
|
@@ -140,8 +143,15 @@ module Hecks
|
|
|
140
143
|
end
|
|
141
144
|
|
|
142
145
|
def decode_for(aggregate, row)
|
|
146
|
+
decode_fields(fields_for(aggregate), aggregate, row)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The field list a row decodes against: every declared attribute,
|
|
150
|
+
# plus the lifecycle field and any `projects` fields not already
|
|
151
|
+
# among them (both read back raw — see `decode_fields`).
|
|
152
|
+
def fields_for(aggregate)
|
|
143
153
|
fields = aggregate.attributes.map { |attribute| [attribute.name, attribute] }
|
|
144
|
-
if (lifecycle = aggregate.lifecycle) &&
|
|
154
|
+
if (lifecycle = aggregate.lifecycle) && fields.none? { |name, _| name == lifecycle.field }
|
|
145
155
|
fields << [lifecycle.field, nil]
|
|
146
156
|
end
|
|
147
157
|
# `projects` FIELDS (S12, ADR 0025) NEED READING BACK TOO — `project`
|
|
@@ -154,8 +164,16 @@ module Hecks
|
|
|
154
164
|
aggregate.projected_fields.each do |field|
|
|
155
165
|
fields << [field.name, nil] unless fields.any? { |name, _| name == field.name }
|
|
156
166
|
end
|
|
167
|
+
fields
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def decode_fields(fields, aggregate, row)
|
|
157
171
|
fields.each_with_object({}) do |(name, attribute), state|
|
|
158
172
|
raw = row[name.to_s]
|
|
173
|
+
# rubocop:disable Lint/DuplicateBranch -- the nil-attribute and
|
|
174
|
+
# reference-id branches both just answer `raw`, coincidentally, for
|
|
175
|
+
# two unrelated reasons (see each branch's own comment); merging
|
|
176
|
+
# them would blur that distinction.
|
|
159
177
|
state[name] =
|
|
160
178
|
if attribute.nil?
|
|
161
179
|
raw
|
|
@@ -165,6 +183,7 @@ module Hecks
|
|
|
165
183
|
# A reference is a scalar id — see Codec#decode.
|
|
166
184
|
raw
|
|
167
185
|
end
|
|
186
|
+
# rubocop:enable Lint/DuplicateBranch
|
|
168
187
|
end
|
|
169
188
|
end
|
|
170
189
|
end
|
|
@@ -10,7 +10,7 @@ module Hecks
|
|
|
10
10
|
def create_aggregate_table!
|
|
11
11
|
columns = persisted_fields.map { |field| "#{quote_ident(field[:name])} #{field[:sql_type]}" }
|
|
12
12
|
@db.execute(
|
|
13
|
-
"CREATE TABLE IF NOT EXISTS #{quoted_table} (id TEXT PRIMARY KEY#{columns.empty?
|
|
13
|
+
"CREATE TABLE IF NOT EXISTS #{quoted_table} (id TEXT PRIMARY KEY#{', ' unless columns.empty?}#{columns.join(', ')})"
|
|
14
14
|
)
|
|
15
15
|
# RIGHT HERE, NOT AS A SEPARATE STEP IN `Sqlite#initialize` —
|
|
16
16
|
# `create_aggregate_table!` is the one piece of DDL `D1`
|
|
@@ -96,6 +96,28 @@ module Hecks
|
|
|
96
96
|
raise unless e.message.include?("duplicate column name")
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
+
def create_outbox_table!
|
|
100
|
+
@db.execute(<<~SQL)
|
|
101
|
+
CREATE TABLE IF NOT EXISTS hecks_outbox (
|
|
102
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
103
|
+
delivery_id TEXT NOT NULL UNIQUE,
|
|
104
|
+
event_uid TEXT NOT NULL,
|
|
105
|
+
aggregate TEXT NOT NULL,
|
|
106
|
+
domain TEXT NOT NULL,
|
|
107
|
+
kind TEXT NOT NULL,
|
|
108
|
+
consumer TEXT NOT NULL,
|
|
109
|
+
event TEXT NOT NULL,
|
|
110
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
111
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
112
|
+
error TEXT,
|
|
113
|
+
enqueued_at TEXT NOT NULL,
|
|
114
|
+
claimed_at TEXT,
|
|
115
|
+
settled_at TEXT
|
|
116
|
+
)
|
|
117
|
+
SQL
|
|
118
|
+
@db.execute("CREATE INDEX IF NOT EXISTS idx_hecks_outbox_status ON hecks_outbox(aggregate, status)")
|
|
119
|
+
end
|
|
120
|
+
|
|
99
121
|
def sql_type(attr)
|
|
100
122
|
return "TEXT" if attr.list?
|
|
101
123
|
|
|
@@ -9,6 +9,7 @@ require_relative "../../query_specification/common/null_policy"
|
|
|
9
9
|
require_relative "../../query_specification/common/order_by"
|
|
10
10
|
require_relative "../../runtime/errors"
|
|
11
11
|
require_relative "../../runtime/event"
|
|
12
|
+
require_relative "../../runtime/outbox"
|
|
12
13
|
require_relative "../../runtime/instance"
|
|
13
14
|
|
|
14
15
|
module Hecks
|
|
@@ -63,6 +64,17 @@ module Hecks
|
|
|
63
64
|
ensure_entry_mirrors_column!
|
|
64
65
|
create_event_table!
|
|
65
66
|
create_saga_table!
|
|
67
|
+
create_outbox_table!
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# RE-ENTRANT ON PURPOSE — `atomic_put` opens its own transaction
|
|
71
|
+
# and `Interpreting#run_dispatch_order` opens one around the whole
|
|
72
|
+
# save+emit pair; SQLite3 refuses a BEGIN inside a BEGIN, so the
|
|
73
|
+
# inner call joins the outer one instead. Same shape Postgres uses.
|
|
74
|
+
def transaction(&)
|
|
75
|
+
return yield if @db.transaction_active?
|
|
76
|
+
|
|
77
|
+
@db.transaction(&)
|
|
66
78
|
end
|
|
67
79
|
|
|
68
80
|
def table = @aggregate.storage_name
|
|
@@ -81,7 +93,10 @@ module Hecks
|
|
|
81
93
|
order_sql = "ORDER BY id"
|
|
82
94
|
if order_by
|
|
83
95
|
name = order_by.to_s.split(".").first
|
|
84
|
-
|
|
96
|
+
unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name)
|
|
97
|
+
raise Runtime::WiringError,
|
|
98
|
+
"#{@aggregate.name} has no attribute #{order_by.inspect} to order by"
|
|
99
|
+
end
|
|
85
100
|
|
|
86
101
|
spec = QuerySpecification::Common::OrderBy.new(field: order_by, direction: direction)
|
|
87
102
|
order_sql = "ORDER BY #{order_clause(spec, nil)}"
|
|
@@ -142,8 +157,10 @@ module Hecks
|
|
|
142
157
|
|
|
143
158
|
def save(instance)
|
|
144
159
|
entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
|
|
145
|
-
|
|
146
|
-
|
|
160
|
+
transaction do
|
|
161
|
+
append(entry)
|
|
162
|
+
project(entry)
|
|
163
|
+
end
|
|
147
164
|
end
|
|
148
165
|
|
|
149
166
|
# The outcome lookup, journal append and snapshot replacement share one
|
|
@@ -151,7 +168,7 @@ module Hecks
|
|
|
151
168
|
# adapter-native operation owns both concurrency and outcome reporting.
|
|
152
169
|
def atomic_put(entry, insert_only: false)
|
|
153
170
|
status = nil
|
|
154
|
-
|
|
171
|
+
transaction do
|
|
155
172
|
exists = !@db.get_first_value("SELECT 1 FROM #{quoted_table} WHERE id = ?", [entry.id.to_s]).nil?
|
|
156
173
|
if insert_only && exists
|
|
157
174
|
status = :conflicted
|
|
@@ -202,11 +219,63 @@ module Hecks
|
|
|
202
219
|
# either way; a domain that wants an obviously-named saga store
|
|
203
220
|
# already gets one by sharing `database` across its aggregates,
|
|
204
221
|
# the recommended, common case.
|
|
222
|
+
# THE OUTBOX — see `Runtime::Outbox`. Rows land in the SAME
|
|
223
|
+
# database as this aggregate (the only way the enqueue shares the
|
|
224
|
+
# save's transaction), keyed by the aggregate's storage name so an
|
|
225
|
+
# adapter instance only ever reads back its own rows even when
|
|
226
|
+
# several aggregates share one file. `INSERT OR IGNORE` on the
|
|
227
|
+
# UNIQUE delivery_id makes a re-enqueue of the same (event,
|
|
228
|
+
# consumer) a no-op; `outbox_claim`'s `WHERE status = 'pending'`
|
|
229
|
+
# is the compare-and-set that lets exactly one relay win a row.
|
|
230
|
+
def outbox_enqueue(rows)
|
|
231
|
+
rows.filter_map do |row|
|
|
232
|
+
@db.execute(
|
|
233
|
+
"INSERT OR IGNORE INTO hecks_outbox (delivery_id, event_uid, aggregate, domain, kind, consumer, event, " \
|
|
234
|
+
"status, attempts, enqueued_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?)",
|
|
235
|
+
[row.delivery_id, row.event_uid, row.aggregate, row.domain, row.kind, row.consumer,
|
|
236
|
+
JSON.generate(row.event), Time.now.utc.iso8601]
|
|
237
|
+
)
|
|
238
|
+
next nil if @db.changes.zero?
|
|
239
|
+
|
|
240
|
+
row.id = @db.last_insert_row_id
|
|
241
|
+
row.status = "pending"
|
|
242
|
+
row
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def outbox_claim(id)
|
|
247
|
+
@db.execute(
|
|
248
|
+
"UPDATE hecks_outbox SET status = 'claimed', attempts = attempts + 1, claimed_at = ? " \
|
|
249
|
+
"WHERE id = ? AND status = 'pending'",
|
|
250
|
+
[Time.now.utc.iso8601, id]
|
|
251
|
+
)
|
|
252
|
+
@db.changes == 1
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def outbox_settle(id, status:, error: nil)
|
|
256
|
+
@db.execute(
|
|
257
|
+
"UPDATE hecks_outbox SET status = ?, error = ?, settled_at = ? WHERE id = ?",
|
|
258
|
+
[status.to_s, error, Time.now.utc.iso8601, id]
|
|
259
|
+
)
|
|
260
|
+
@db.changes == 1
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def outbox_rows(status: nil)
|
|
264
|
+
sql = "SELECT * FROM hecks_outbox WHERE aggregate = ?"
|
|
265
|
+
binds = [table]
|
|
266
|
+
if status
|
|
267
|
+
sql << " AND status = ?"
|
|
268
|
+
binds << status.to_s
|
|
269
|
+
end
|
|
270
|
+
@db.execute("#{sql} ORDER BY id", binds).map { |row| outbox_row(row) }
|
|
271
|
+
end
|
|
272
|
+
|
|
205
273
|
def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
|
|
206
274
|
@db.execute(
|
|
207
|
-
"INSERT OR REPLACE INTO hecks_saga_instances (domain, process_manager, correlation, state, memory,
|
|
208
|
-
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
209
|
-
[@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory),
|
|
275
|
+
"INSERT OR REPLACE INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, " \
|
|
276
|
+
"completed_compensations) VALUES (?, ?, ?, ?, ?, ?)",
|
|
277
|
+
[@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory),
|
|
278
|
+
JSON.generate(completed_compensations)]
|
|
210
279
|
)
|
|
211
280
|
end
|
|
212
281
|
|
|
@@ -221,7 +290,8 @@ module Hecks
|
|
|
221
290
|
return enum_for(:each_saga) unless block_given?
|
|
222
291
|
|
|
223
292
|
@db.execute(
|
|
224
|
-
"SELECT process_manager, correlation, state, memory, completed_compensations
|
|
293
|
+
"SELECT process_manager, correlation, state, memory, completed_compensations " \
|
|
294
|
+
"FROM hecks_saga_instances WHERE domain = ?",
|
|
225
295
|
[@domain]
|
|
226
296
|
).each do |row|
|
|
227
297
|
yield row["process_manager"], row["correlation"], row["state"],
|
|
@@ -234,6 +304,15 @@ module Hecks
|
|
|
234
304
|
|
|
235
305
|
# ── SqlQueryBuilder's dialect hooks ─────────────────────────────
|
|
236
306
|
|
|
307
|
+
def outbox_row(row)
|
|
308
|
+
Runtime::Outbox::Row.new(
|
|
309
|
+
id: row["id"], delivery_id: row["delivery_id"], event_uid: row["event_uid"], aggregate: row["aggregate"],
|
|
310
|
+
domain: row["domain"], kind: row["kind"], consumer: row["consumer"],
|
|
311
|
+
event: JSON.parse(row["event"], symbolize_names: true), status: row["status"],
|
|
312
|
+
attempts: row["attempts"].to_i, error: row["error"]
|
|
313
|
+
)
|
|
314
|
+
end
|
|
315
|
+
|
|
237
316
|
def select_list = "*"
|
|
238
317
|
def from_relation = quoted_table
|
|
239
318
|
def dialect_name = "SQLite"
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
module Hecks
|
|
2
|
+
# Reopened here purely to require in every driven adapter under
|
|
3
|
+
# adapters/driven/ below (see the comment inside for what "driven side"
|
|
4
|
+
# means) — the actual namespace is declared in adapters.rb.
|
|
2
5
|
module Adapters
|
|
3
6
|
# The driven side — every store or reader an adapter declaration can
|
|
4
7
|
# bind. A small adapter is one file beside this one; sqlite, postgres
|
|
@@ -32,6 +35,7 @@ require_relative "driven/postgres"
|
|
|
32
35
|
Hecks::Adapters.autoload(:PostgresEra, "hecks/ports/persistence/plugins/era")
|
|
33
36
|
require_relative "driven/lambda"
|
|
34
37
|
require_relative "driven/heki"
|
|
38
|
+
require_relative "driven/local_storage"
|
|
35
39
|
require_relative "driven/prism"
|
|
36
40
|
require_relative "driven/folder"
|
|
37
41
|
require_relative "driven/d1"
|