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
|
@@ -9,6 +9,7 @@ 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"
|
|
12
13
|
require_relative "../../runtime/instance"
|
|
13
14
|
|
|
14
15
|
module Hecks
|
|
@@ -44,6 +45,7 @@ module Hecks
|
|
|
44
45
|
include SqlQueryBuilder
|
|
45
46
|
include SchemaBuilder
|
|
46
47
|
include Codec
|
|
48
|
+
include PostgresOutbox
|
|
47
49
|
|
|
48
50
|
SQL_TYPES = { "Integer" => "bigint", "Float" => "double precision" }.freeze
|
|
49
51
|
|
|
@@ -84,9 +86,9 @@ module Hecks
|
|
|
84
86
|
# boot after the first, not news.
|
|
85
87
|
connection.exec("SET client_min_messages = warning")
|
|
86
88
|
connection
|
|
87
|
-
rescue PG::Error =>
|
|
89
|
+
rescue PG::Error => e
|
|
88
90
|
raise Runtime::WiringError,
|
|
89
|
-
"cannot bind Postgres at #{declared} for #{name}: #{
|
|
91
|
+
"cannot bind Postgres at #{declared} for #{name}: #{e.message.strip}"
|
|
90
92
|
end
|
|
91
93
|
|
|
92
94
|
def initialize(aggregate:, settings: {}, root: nil)
|
|
@@ -110,6 +112,7 @@ module Hecks
|
|
|
110
112
|
create_entry_table!
|
|
111
113
|
create_event_table!
|
|
112
114
|
create_saga_table!
|
|
115
|
+
create_outbox_table!
|
|
113
116
|
end
|
|
114
117
|
|
|
115
118
|
def table = @aggregate.storage_name
|
|
@@ -128,7 +131,10 @@ module Hecks
|
|
|
128
131
|
order_sql = "ORDER BY id"
|
|
129
132
|
if order_by
|
|
130
133
|
name = order_by.to_s.split(".").first
|
|
131
|
-
|
|
134
|
+
unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name)
|
|
135
|
+
raise Runtime::WiringError,
|
|
136
|
+
"#{@aggregate.name} has no attribute #{order_by.inspect} to order by"
|
|
137
|
+
end
|
|
132
138
|
|
|
133
139
|
spec = QuerySpecification::Common::OrderBy.new(field: order_by, direction: direction)
|
|
134
140
|
order_sql = "ORDER BY #{order_clause(spec, nil)}"
|
|
@@ -169,6 +175,8 @@ module Hecks
|
|
|
169
175
|
# zero rows back means the conflict branch's WHERE excluded the row
|
|
170
176
|
# entirely — the version had already moved — so `nil` is returned
|
|
171
177
|
# for the caller (`AppendOnly#save`) to treat as "stale, no-op".
|
|
178
|
+
# rubocop:disable Metrics/AbcSize -- the CAS/plain upsert split is one
|
|
179
|
+
# protocol; splitting it would hide the version handshake.
|
|
172
180
|
def project(entry, expected_version: nil)
|
|
173
181
|
return @db.exec_params("DELETE FROM #{quoted_table} WHERE id = $1", [entry.id]) if entry.delete?
|
|
174
182
|
|
|
@@ -193,6 +201,7 @@ module Hecks
|
|
|
193
201
|
instance.version = result[0]["hecks_version"].to_i
|
|
194
202
|
instance
|
|
195
203
|
end
|
|
204
|
+
# rubocop:enable Metrics/AbcSize
|
|
196
205
|
|
|
197
206
|
def entries
|
|
198
207
|
@db.exec("SELECT aggregate_id, operation, state, mirrors FROM #{quoted_entry_table} ORDER BY sequence").map do |row|
|
|
@@ -221,18 +230,15 @@ module Hecks
|
|
|
221
230
|
# transaction lives here, the one caller that runs both together.
|
|
222
231
|
def save(instance)
|
|
223
232
|
entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
|
|
224
|
-
|
|
233
|
+
transaction do
|
|
234
|
+
append(entry)
|
|
235
|
+
project(entry)
|
|
236
|
+
end
|
|
225
237
|
end
|
|
226
238
|
|
|
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
239
|
def atomic_put(entry, insert_only: false)
|
|
234
240
|
status = nil
|
|
235
|
-
|
|
241
|
+
transaction do
|
|
236
242
|
@db.exec_params(
|
|
237
243
|
"SELECT pg_advisory_xact_lock(" \
|
|
238
244
|
"hashtext(current_schema() || ':' || $1), hashtext($2))",
|
|
@@ -255,7 +261,10 @@ module Hecks
|
|
|
255
261
|
|
|
256
262
|
def delete(id)
|
|
257
263
|
entry = Ports::Persistence::Entry.new(operation: "delete", id: id.to_s, state: nil)
|
|
258
|
-
|
|
264
|
+
transaction do
|
|
265
|
+
append(entry)
|
|
266
|
+
project(entry)
|
|
267
|
+
end
|
|
259
268
|
true
|
|
260
269
|
end
|
|
261
270
|
|
|
@@ -288,7 +297,8 @@ module Hecks
|
|
|
288
297
|
"ON CONFLICT (domain, process_manager, correlation) DO UPDATE " \
|
|
289
298
|
"SET state = EXCLUDED.state, memory = EXCLUDED.memory, " \
|
|
290
299
|
"completed_compensations = EXCLUDED.completed_compensations, updated_at = now()",
|
|
291
|
-
[@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory),
|
|
300
|
+
[@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory),
|
|
301
|
+
JSON.generate(completed_compensations)]
|
|
292
302
|
)
|
|
293
303
|
end
|
|
294
304
|
|
|
@@ -303,7 +313,8 @@ module Hecks
|
|
|
303
313
|
return enum_for(:each_saga) unless block_given?
|
|
304
314
|
|
|
305
315
|
@db.exec_params(
|
|
306
|
-
"SELECT process_manager, correlation, state, memory, completed_compensations
|
|
316
|
+
"SELECT process_manager, correlation, state, memory, completed_compensations " \
|
|
317
|
+
"FROM hecks_saga_instances WHERE domain = $1",
|
|
307
318
|
[@domain]
|
|
308
319
|
).each do |row|
|
|
309
320
|
yield row["process_manager"], row["correlation"], row["state"],
|
|
@@ -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
|
|
@@ -140,8 +140,15 @@ module Hecks
|
|
|
140
140
|
end
|
|
141
141
|
|
|
142
142
|
def decode_for(aggregate, row)
|
|
143
|
+
decode_fields(fields_for(aggregate), aggregate, row)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The field list a row decodes against: every declared attribute,
|
|
147
|
+
# plus the lifecycle field and any `projects` fields not already
|
|
148
|
+
# among them (both read back raw — see `decode_fields`).
|
|
149
|
+
def fields_for(aggregate)
|
|
143
150
|
fields = aggregate.attributes.map { |attribute| [attribute.name, attribute] }
|
|
144
|
-
if (lifecycle = aggregate.lifecycle) &&
|
|
151
|
+
if (lifecycle = aggregate.lifecycle) && fields.none? { |name, _| name == lifecycle.field }
|
|
145
152
|
fields << [lifecycle.field, nil]
|
|
146
153
|
end
|
|
147
154
|
# `projects` FIELDS (S12, ADR 0025) NEED READING BACK TOO — `project`
|
|
@@ -154,8 +161,16 @@ module Hecks
|
|
|
154
161
|
aggregate.projected_fields.each do |field|
|
|
155
162
|
fields << [field.name, nil] unless fields.any? { |name, _| name == field.name }
|
|
156
163
|
end
|
|
164
|
+
fields
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def decode_fields(fields, aggregate, row)
|
|
157
168
|
fields.each_with_object({}) do |(name, attribute), state|
|
|
158
169
|
raw = row[name.to_s]
|
|
170
|
+
# rubocop:disable Lint/DuplicateBranch -- the nil-attribute and
|
|
171
|
+
# reference-id branches both just answer `raw`, coincidentally, for
|
|
172
|
+
# two unrelated reasons (see each branch's own comment); merging
|
|
173
|
+
# them would blur that distinction.
|
|
159
174
|
state[name] =
|
|
160
175
|
if attribute.nil?
|
|
161
176
|
raw
|
|
@@ -165,6 +180,7 @@ module Hecks
|
|
|
165
180
|
# A reference is a scalar id — see Codec#decode.
|
|
166
181
|
raw
|
|
167
182
|
end
|
|
183
|
+
# rubocop:enable Lint/DuplicateBranch
|
|
168
184
|
end
|
|
169
185
|
end
|
|
170
186
|
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"
|
data/lib/hecks/adapters.rb
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
module Hecks
|
|
2
|
+
# Namespace for every driven-side adapter (Memory, Folder, SQLite,
|
|
3
|
+
# Postgres, Heki, ...) a `persisted_by`/port binding can resolve to;
|
|
4
|
+
# concrete adapters are required in below from adapters/driven and
|
|
5
|
+
# register themselves into this namespace as they load.
|
|
2
6
|
module Adapters
|
|
3
7
|
end
|
|
4
8
|
end
|
data/lib/hecks/behaviors/dsl.rb
CHANGED
|
@@ -11,6 +11,10 @@ module Hecks
|
|
|
11
11
|
# inside a boot, over a suite that was never going to be scoped.
|
|
12
12
|
class Malformed < StandardError; end
|
|
13
13
|
|
|
14
|
+
# The `test "description" do ... end` block's own receiver — collects
|
|
15
|
+
# `tests`/`setup`/`input`/`expect` calls and builds a `TestCase` (ir.rb).
|
|
16
|
+
# `validate_expect!` (private, below) is where a malformed or empty
|
|
17
|
+
# `expect` is refused at build time rather than silently passing later.
|
|
14
18
|
class TestCaseBuilder
|
|
15
19
|
def initialize(description)
|
|
16
20
|
@description = description
|
|
@@ -76,6 +80,9 @@ module Hecks
|
|
|
76
80
|
end
|
|
77
81
|
end
|
|
78
82
|
|
|
83
|
+
# The top-level `Hecks.behaviors "Name" do ... end` receiver — collects
|
|
84
|
+
# `vision`/`loads`/`test` calls and builds a `BehaviorsSuite` (ir.rb),
|
|
85
|
+
# refusing to build one missing either `vision` or `loads` (`#build`).
|
|
79
86
|
class BehaviorsBuilder
|
|
80
87
|
def initialize(name, source_path:)
|
|
81
88
|
@name = name
|
|
@@ -38,6 +38,8 @@ require_relative "../ports/persistence/binding_policy"
|
|
|
38
38
|
# always on and `emits:` is expected to see them.
|
|
39
39
|
module Hecks
|
|
40
40
|
module Behaviors
|
|
41
|
+
# See this file's own header above for what this module does and the
|
|
42
|
+
# two deliberate deviations from the prior port it's built from.
|
|
41
43
|
module Expectations
|
|
42
44
|
module_function
|
|
43
45
|
|
|
@@ -75,6 +77,10 @@ module Hecks
|
|
|
75
77
|
# loop or a long rspec session actually relies on. `runtime:` lets
|
|
76
78
|
# a caller that already holds a booted runtime (a spec, a REPL)
|
|
77
79
|
# hand it in; it is reset the same way.
|
|
80
|
+
# NOT frozen — a real cache, mutated below (`RUNTIMES[key] ||=
|
|
81
|
+
# boot_and_guard(files)`) and by #reset!. False positive for
|
|
82
|
+
# Style/MutableConstant.
|
|
83
|
+
# rubocop:disable-next Style/MutableConstant
|
|
78
84
|
RUNTIMES = {}
|
|
79
85
|
RUNTIMES_LOCK = Mutex.new
|
|
80
86
|
private_constant :RUNTIMES, :RUNTIMES_LOCK
|
|
@@ -137,11 +143,17 @@ module Hecks
|
|
|
137
143
|
before = runtime.registry.event_log.length
|
|
138
144
|
result = dispatch_command(runtime, verb, test.input)
|
|
139
145
|
|
|
140
|
-
|
|
146
|
+
if test.expect.key?(:refused)
|
|
147
|
+
return fail_result(test,
|
|
148
|
+
"expected refused: #{test.expect[:refused].inspect} but dispatch succeeded")
|
|
149
|
+
end
|
|
141
150
|
|
|
142
151
|
if (expected_emits = test.expect[:emits])
|
|
143
152
|
actual = runtime.registry.event_log[before..].map(&:name)
|
|
144
|
-
|
|
153
|
+
unless actual == expected_emits
|
|
154
|
+
return fail_result(test,
|
|
155
|
+
"expected emits: #{expected_emits.inspect}, got #{actual.inspect}")
|
|
156
|
+
end
|
|
145
157
|
end
|
|
146
158
|
|
|
147
159
|
check_ok(test) || check_fields(test, settled_state(runtime, verb, result)) || pass_result(test)
|
|
@@ -205,7 +217,10 @@ module Hecks
|
|
|
205
217
|
def run_query(test, runtime, verb)
|
|
206
218
|
rows = runtime.query(verb, **test.input)
|
|
207
219
|
|
|
208
|
-
|
|
220
|
+
if test.expect.key?(:refused)
|
|
221
|
+
return fail_result(test,
|
|
222
|
+
"expected refused: #{test.expect[:refused].inspect} but the query succeeded")
|
|
223
|
+
end
|
|
209
224
|
|
|
210
225
|
if (expected = test.expect[:count])
|
|
211
226
|
count = if rows.is_a?(Array)
|
|
@@ -307,6 +322,14 @@ module Hecks
|
|
|
307
322
|
def qualify(command, on_aggregate, bluebooks, kind:)
|
|
308
323
|
return command.to_s if command.to_s.include?(".")
|
|
309
324
|
|
|
325
|
+
candidates = qualify_candidates(command, on_aggregate, bluebooks, kind)
|
|
326
|
+
disambiguate_qualified_name(candidates, command, kind, bluebooks)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# THE SEARCH — every (bluebook, aggregate) pair that declares a
|
|
330
|
+
# command/query named `command`, narrowed to `on_aggregate` by name
|
|
331
|
+
# when given.
|
|
332
|
+
def qualify_candidates(command, on_aggregate, bluebooks, kind)
|
|
310
333
|
members = kind == :query ? :queries : :commands
|
|
311
334
|
pairs =
|
|
312
335
|
if on_aggregate
|
|
@@ -314,8 +337,13 @@ module Hecks
|
|
|
314
337
|
else
|
|
315
338
|
bluebooks.flat_map { |bb| bb.aggregates.map { |agg| [bb, agg] } }
|
|
316
339
|
end
|
|
317
|
-
|
|
340
|
+
pairs.select { |_, agg| agg.public_send(members).any? { |m| m.hecks_name == command.to_s } }
|
|
341
|
+
end
|
|
318
342
|
|
|
343
|
+
# THE REPORT — zero candidates and more-than-one candidates both
|
|
344
|
+
# refuse (with a different message); exactly one resolves to its
|
|
345
|
+
# dotted FQN.
|
|
346
|
+
def disambiguate_qualified_name(candidates, command, kind, bluebooks)
|
|
319
347
|
case candidates.size
|
|
320
348
|
when 0
|
|
321
349
|
raise ArgumentError, "no aggregate among #{bluebooks.map(&:name).inspect} declares a #{kind} " \
|
|
@@ -6,6 +6,10 @@ require_relative "expectations"
|
|
|
6
6
|
# that actually touches a live runtime; this file finds `.behaviors`
|
|
7
7
|
# files, loads them, and maps their tests through it.
|
|
8
8
|
module Hecks
|
|
9
|
+
# See this file's own header above for `.run`/`.run_all`/`.parse` — the
|
|
10
|
+
# discovery-and-aggregation half of the `.behaviors` authoring surface;
|
|
11
|
+
# `Expectations` (expectations.rb) is the impure edge that actually runs
|
|
12
|
+
# one test against a live runtime.
|
|
9
13
|
module Behaviors
|
|
10
14
|
FileResult = Struct.new(:path, :parse_error, :runs, keyword_init: true)
|
|
11
15
|
SweepResult = Struct.new(:root, :files_swept, :files, :summary, keyword_init: true)
|