hecks 0.3.0 → 1.0.1
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/d1.rb +38 -14
- data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
- data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
- data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
- data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
- data/lib/hecks/adapters/driven/heki.rb +40 -9
- data/lib/hecks/adapters/driven/lambda.rb +16 -2
- data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
- data/lib/hecks/adapters/driven/postgres.rb +76 -29
- data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
- data/lib/hecks/adapters/driven/sql_query_builder.rb +1 -1
- data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
- data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
- data/lib/hecks/adapters/driven/sqlite.rb +30 -9
- data/lib/hecks/adapters/driven.rb +19 -1
- data/lib/hecks/behaviors/dsl.rb +29 -0
- data/lib/hecks/behaviors/expectations.rb +62 -2
- data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
- data/lib/hecks/bluebook/assembly/marks.rb +4 -3
- data/lib/hecks/bluebook/assembly.rb +14 -1
- data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
- data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
- data/lib/hecks/bluebook/behaviour/value_object.rb +13 -7
- data/lib/hecks/bluebook/chapter.rb +21 -11
- data/lib/hecks/bluebook/command.rb +1 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
- data/lib/hecks/bluebook/dsl/command_builder.rb +144 -3
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +49 -7
- data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
- data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
- data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
- data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
- data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
- data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
- data/lib/hecks/bluebook/expression/ast_json.rb +149 -0
- data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
- data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
- data/lib/hecks/bluebook/expression/projection.json +48 -0
- data/lib/hecks/bluebook/expression/resolver.rb +161 -10
- data/lib/hecks/bluebook/expression.rb +1 -0
- data/lib/hecks/bluebook/hexagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
- data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
- data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
- data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
- data/lib/hecks/bluebook/meta_validator.rb +103 -13
- data/lib/hecks/bluebook/model_check.rb +147 -6
- data/lib/hecks/bluebook/pattern_subset.rb +66 -2
- data/lib/hecks/bluebook/process_manager.rb +53 -11
- data/lib/hecks/bluebook/project_register.rb +25 -0
- data/lib/hecks/bluebook/value_object.rb +19 -2
- data/lib/hecks/doc/reference.rb +22 -1
- data/lib/hecks/facade/cli_door.rb +6 -3
- data/lib/hecks/facade/json_door.rb +16 -4
- data/lib/hecks/forms/app.rb +47 -6
- data/lib/hecks/forms/command_form_renderer.rb +1 -1
- data/lib/hecks/forms/field_renderer.rb +11 -4
- data/lib/hecks/forms/html.rb +31 -0
- data/lib/hecks/forms/params.rb +30 -1
- data/lib/hecks/forms/port_argument.rb +46 -0
- data/lib/hecks/forms/record_renderer.rb +6 -2
- data/lib/hecks/forms/record_table.rb +6 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
- data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
- data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
- data/lib/hecks/fuzzing/invalid_value_generator.rb +3 -2
- data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
- data/lib/hecks/fuzzing/properties.rb +52 -6
- data/lib/hecks/fuzzing/replay.rb +51 -18
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
- data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
- data/lib/hecks/fuzzing/value_generator.rb +25 -2
- data/lib/hecks/grammar/evolve.rb +33 -0
- data/lib/hecks/grammar/expression.bluebook +8 -5
- data/lib/hecks/grammar/expression_operators.json +497 -1
- data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
- data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
- data/lib/hecks/language/bluebook/command.bluebook +42 -8
- data/lib/hecks/language/bluebook/entity.bluebook +86 -10
- data/lib/hecks/language/bluebook/policy.bluebook +21 -4
- data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
- data/lib/hecks/language/bluebook/projection.bluebook +6 -6
- data/lib/hecks/language/bluebook/query.bluebook +4 -4
- data/lib/hecks/language/bluebook/shape.bluebook +6 -6
- data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
- data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
- data/lib/hecks/language/hecksagon/domain_port.bluebook +25 -1
- data/lib/hecks/language/oidc.json +20 -0
- data/lib/hecks/language/port.bluebook +30 -2
- data/lib/hecks/naming.rb +54 -1
- data/lib/hecks/ports/access_control.port +7 -2
- data/lib/hecks/ports/access_control.rb +1 -1
- data/lib/hecks/ports/agent.port +6 -2
- data/lib/hecks/ports/agent.rb +1 -1
- data/lib/hecks/ports/authentication.port +4 -2
- data/lib/hecks/ports/authentication.rb +1 -1
- data/lib/hecks/ports/authorization.port +5 -2
- data/lib/hecks/ports/authorization.rb +14 -11
- data/lib/hecks/ports/clock.port +3 -2
- data/lib/hecks/ports/clock.rb +1 -1
- data/lib/hecks/ports/extraction.port +3 -2
- data/lib/hecks/ports/extraction.rb +1 -1
- data/lib/hecks/ports/identity_assignment.port +3 -2
- data/lib/hecks/ports/identity_assignment.rb +1 -1
- data/lib/hecks/ports/identity_generation.port +3 -2
- data/lib/hecks/ports/identity_generation.rb +1 -1
- data/lib/hecks/ports/identity_resolution.port +3 -2
- data/lib/hecks/ports/identity_resolution.rb +1 -1
- data/lib/hecks/ports/persistence/append_only.rb +40 -4
- data/lib/hecks/ports/persistence/execution.rb +6 -1
- data/lib/hecks/ports/persistence/plugin.rb +54 -0
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
- data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
- data/lib/hecks/ports/persistence.rb +1 -1
- data/lib/hecks/ports/projection.rb +20 -0
- data/lib/hecks/projections/diagrams.rb +230 -1
- data/lib/hecks/projections/shape.rb +15 -2
- data/lib/hecks/projector/cli_projector.rb +10 -1
- data/lib/hecks/projector/exporter.rb +19 -2
- data/lib/hecks/query_ir.rb +19 -0
- data/lib/hecks/query_specification/common/null_policy.rb +13 -1
- data/lib/hecks/query_specification/field_path.rb +20 -2
- data/lib/hecks/query_specification/hop_path.rb +7 -5
- data/lib/hecks/runtime/aggregate_lock.rb +45 -0
- data/lib/hecks/runtime/boot_gates.rb +41 -0
- data/lib/hecks/runtime/caller.rb +19 -3
- data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
- data/lib/hecks/runtime/command_interpreter.rb +165 -34
- data/lib/hecks/runtime/command_rules/admissibility.rb +101 -16
- data/lib/hecks/runtime/command_rules/arithmetic.rb +40 -4
- data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
- data/lib/hecks/runtime/command_rules/references.rb +27 -19
- data/lib/hecks/runtime/dependency_planning.rb +66 -5
- data/lib/hecks/runtime/dispatcher.rb +19 -4
- data/lib/hecks/runtime/entity_interpreter.rb +92 -14
- data/lib/hecks/runtime/errors.rb +22 -0
- data/lib/hecks/runtime/identity.rb +30 -2
- data/lib/hecks/runtime/instance.rb +59 -4
- data/lib/hecks/runtime/interpreting.rb +21 -0
- data/lib/hecks/runtime/loader.rb +59 -18
- data/lib/hecks/runtime/query_interpreter.rb +36 -4
- data/lib/hecks/runtime/reaction_invocation.rb +9 -1
- data/lib/hecks/runtime/read_model_interpreter.rb +89 -5
- data/lib/hecks/runtime/refusal_wording.rb +2 -0
- data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
- data/lib/hecks/runtime/registry/verification.rb +88 -0
- data/lib/hecks/runtime/registry.rb +69 -8
- data/lib/hecks/runtime/saga_interpreter.rb +215 -13
- data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
- data/lib/hecks/runtime/value/admission.rb +19 -1
- data/lib/hecks/runtime/value/coercion.rb +75 -10
- data/lib/hecks/runtime.rb +17 -5
- data/lib/hecks/storehouse.rb +632 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +6 -1
- data/lib/hecks.rb +28 -3
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
- metadata +49 -37
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
- /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
|
@@ -48,7 +48,19 @@ module Hecks
|
|
|
48
48
|
# declares at least one command
|
|
49
49
|
# or query — everything you can
|
|
50
50
|
# DO to it and ASK about it,
|
|
51
|
+
# AND what each command WRITES,
|
|
51
52
|
# in one place
|
|
53
|
+
# <Name>_saga.mmd stateDiagram-v2 one per process_manager —
|
|
54
|
+
# its own states, and what
|
|
55
|
+
# each transition dispatches
|
|
56
|
+
# elsewhere in the domain
|
|
57
|
+
# frameworks.mmd flowchart every OTHER domain this one
|
|
58
|
+
# depends on — a shared
|
|
59
|
+
# framework it `uses_framework`,
|
|
60
|
+
# or a domain a policy reaches
|
|
61
|
+
# `across` — the one diagram
|
|
62
|
+
# here that looks OUTWARD past
|
|
63
|
+
# this domain's own boundary
|
|
52
64
|
#
|
|
53
65
|
# CONSTRUCT NAMES (aggregate/entity/command/event) ARE USED BARE,
|
|
54
66
|
# UNSANITIZED, as Mermaid node/entity ids — safe because this
|
|
@@ -100,6 +112,14 @@ module Hecks
|
|
|
100
112
|
files["#{holder.hecks_name}_surface.mmd"] = surface_diagram(bluebook, holder)
|
|
101
113
|
end
|
|
102
114
|
|
|
115
|
+
bluebook.process_managers.each do |saga|
|
|
116
|
+
files["#{saga.hecks_name}_saga.mmd"] = saga_diagram(bluebook, saga)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if (diagram = frameworks_diagram(bluebook, options[:hecksagon]))
|
|
120
|
+
files["frameworks.mmd"] = diagram
|
|
121
|
+
end
|
|
122
|
+
|
|
103
123
|
files
|
|
104
124
|
end
|
|
105
125
|
|
|
@@ -397,17 +417,226 @@ module Hecks
|
|
|
397
417
|
# anything. Command edges are solid ("does"); query edges are
|
|
398
418
|
# dotted ("asks") — the same solid/dotted split `ports.mmd`
|
|
399
419
|
# already uses for "routes to:" versus "exposes".
|
|
420
|
+
#
|
|
421
|
+
# A WRITE TARGET IS A PLAIN RECTANGLE — a sixth shape, the first
|
|
422
|
+
# here with no special bracket at all: an attribute is the
|
|
423
|
+
# smallest, most passive thing this vocabulary names, a single
|
|
424
|
+
# field living INSIDE the cylinder rather than a bounded thing of
|
|
425
|
+
# its own. `command.mutations` (`sets`/`increment`/`decrement`/
|
|
426
|
+
# `append`) was invisible everywhere before this — not just in a
|
|
427
|
+
# diagram, in ANY projection, including the prose ones — despite
|
|
428
|
+
# being the single densest fact in the whole IR (53 real
|
|
429
|
+
# mutations across pizzas + banking). `dispatch.mmd` draws what a
|
|
430
|
+
# command EMITS; this draws what it WRITES, the other half of
|
|
431
|
+
# "what actually happens" a command never showed before.
|
|
432
|
+
#
|
|
433
|
+
# THE SAME ATTRIBUTE NODE MERGES ACROSS COMMANDS — real in
|
|
434
|
+
# banking: `Account.Credit` and `Account.Debit` both point at the
|
|
435
|
+
# same `balance` node, the same "one node, several incoming
|
|
436
|
+
# edges" merge `read_models.mmd` already does for an aggregate
|
|
437
|
+
# fed by several read_models.
|
|
438
|
+
#
|
|
439
|
+
# THE LABEL NAMES THE REAL SOURCE, NOT JUST THE VERB — an
|
|
440
|
+
# increment/decrement/set almost always takes its value from an
|
|
441
|
+
# argument, but not always the SAME-NAMED one: real in banking,
|
|
442
|
+
# `Account.Credit`'s own `balance` is incremented by its
|
|
443
|
+
# `amount` argument, and `LedgerEntry.Amend`'s own `amount` is
|
|
444
|
+
# incremented by its `adjustment` argument. A literal source
|
|
445
|
+
# (pizzas' own `Order.Purchase` sets `status` to the literal
|
|
446
|
+
# `"sold"`, not an argument at all) is named as verbatim as
|
|
447
|
+
# every other fact in this file. `append`'s own fields carry no
|
|
448
|
+
# single source at all — its own field NAMES are the fact worth
|
|
449
|
+
# stating (real: `Order.AddTopping` appends `name, amount`).
|
|
400
450
|
def surface_diagram(bluebook, holder)
|
|
401
451
|
lines = holder.commands.map { |command| " #{holder.hecks_name}[(#{holder.hecks_name})] -->|does| #{command_node(holder.hecks_name, command.hecks_name)}" }
|
|
452
|
+
lines += holder.commands.flat_map { |command| command.mutations.map { |mutation| mutation_edge(holder, command, mutation) } }
|
|
402
453
|
lines += holder.queries.map { |query| " #{holder.hecks_name}[(#{holder.hecks_name})] -.->|asks| #{query_node(holder.hecks_name, query.hecks_name)}" }
|
|
403
454
|
|
|
404
|
-
subject = "#{holder.hecks_name}'s own declared commands and queries"
|
|
455
|
+
subject = "#{holder.hecks_name}'s own declared commands (and what each writes) and queries"
|
|
405
456
|
"#{header(bluebook.name, subject)}flowchart LR\n#{lines.uniq.join("\n")}\n"
|
|
406
457
|
end
|
|
407
458
|
|
|
408
459
|
def query_node(aggregate_name, query_name)
|
|
409
460
|
%(qry_#{aggregate_name}_#{query_name}{"#{aggregate_name}.#{query_name}"})
|
|
410
461
|
end
|
|
462
|
+
|
|
463
|
+
def mutation_edge(holder, command, mutation)
|
|
464
|
+
shape = mutation.to_h
|
|
465
|
+
label = mutation_label(shape)
|
|
466
|
+
target = attribute_node(holder.hecks_name, shape[:target])
|
|
467
|
+
%( #{command_node(holder.hecks_name, command.hecks_name)} -->|"#{label}"| #{target})
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def mutation_label(shape)
|
|
471
|
+
verb = "#{shape[:op]}s"
|
|
472
|
+
# `fields:` (not `source:`) IS the multi-binding shape
|
|
473
|
+
# (`Mutation#to_h`'s own `[:append, :delegate, :corrects]`
|
|
474
|
+
# branch) — checked by the KEY'S PRESENCE, not by re-listing
|
|
475
|
+
# which ops use it a second time here, the same lesson
|
|
476
|
+
# `Change.op`'s own `admits: Vocabulary::MutationOp` already
|
|
477
|
+
# drew (command.bluebook's own comment): a second list of "the
|
|
478
|
+
# ops that mean multi-binding" is exactly the kind of copy that
|
|
479
|
+
# drifts — `:delegate` already carried this shape with nothing
|
|
480
|
+
# here reading it correctly, caught only once `:corrects` gave
|
|
481
|
+
# banking's own real diagrams a fields:-shaped mutation to
|
|
482
|
+
# actually render.
|
|
483
|
+
detail = shape[:fields] ? shape[:fields].keys.join(", ") : mutation_source_detail(shape[:source])
|
|
484
|
+
"#{verb}: #{detail}"
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# A LITERAL VALUE CAN CONTAIN A DOUBLE QUOTE OF ITS OWN — real in
|
|
488
|
+
# banking: `Customer.Reinstate` sets `standing` to a rendered
|
|
489
|
+
# value-object literal, `{:value=>"good"}`, whose own embedded `"`
|
|
490
|
+
# broke this label's outer `|"..."|` quoting outright (caught by
|
|
491
|
+
# running the real generated output through mermaid.parse(), not
|
|
492
|
+
# by eye — the same way `read_models.mmd`'s own unquoted `[]` bug
|
|
493
|
+
# was caught). Swapped for a single quote here rather than
|
|
494
|
+
# escaped, the same "state it, don't invent it, just make it
|
|
495
|
+
# legal Mermaid" trade `read_models.mmd`'s own quoting fix made.
|
|
496
|
+
def mutation_source_detail(source)
|
|
497
|
+
case source[:kind]
|
|
498
|
+
when "literal" then "'#{source[:value].to_s.tr('"', "'")}'"
|
|
499
|
+
when "argument" then source[:name]
|
|
500
|
+
else source[:kind] # a source kind this file has no real corpus example of yet — named, not hidden
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def attribute_node(holder_name, attribute_name)
|
|
505
|
+
%(attr_#{holder_name}_#{attribute_name}[#{attribute_name}])
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# ── sagas -> stateDiagram-v2 ─────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
# A SAGA HAS A LIFECYCLE TOO — the same `stateDiagram-v2` shape
|
|
511
|
+
# `lifecycle_diagram` already draws, one file per process_manager
|
|
512
|
+
# the same way lifecycle is one file per lifecycle-bearing holder.
|
|
513
|
+
# What's different is the label: a lifecycle's own edge is labeled
|
|
514
|
+
# by the COMMAND that causes it (an aggregate transitions because
|
|
515
|
+
# something was DONE to it); a saga's edge is labeled by the EVENT
|
|
516
|
+
# that causes it (a saga advances because something HAPPENED,
|
|
517
|
+
# possibly nowhere near the saga itself) — the same command/event
|
|
518
|
+
# split `dispatch.mmd`'s own stadium/hexagon vocabulary already
|
|
519
|
+
# draws, here spent on which noun labels a stateDiagram-v2 edge
|
|
520
|
+
# instead.
|
|
521
|
+
#
|
|
522
|
+
# THE LABEL ALSO NAMES WHAT THE TRANSITION DISPATCHES — a fact no
|
|
523
|
+
# existing diagram states for a saga at all: a lifecycle's own
|
|
524
|
+
# edge only ever names the one command that caused it; a saga's
|
|
525
|
+
# edge can fire several commands at once (real in banking:
|
|
526
|
+
# Settlement's own AccountDebited handler dispatches both
|
|
527
|
+
# Transfer.Debited and Account.Credit). Confirmed real in the
|
|
528
|
+
# corpus: no saga dispatch ever declares a `to:`/`target_domain`
|
|
529
|
+
# of its own (unlike a policy's `across`) — every command a saga
|
|
530
|
+
# fires lands inside its own bluebook chapter, so this never needs
|
|
531
|
+
# `dispatch.mmd`'s own "triggers in X" cross-domain label.
|
|
532
|
+
#
|
|
533
|
+
# THE COMPENSATING LEG READS LIKE ANY OTHER — its own trigger is
|
|
534
|
+
# the literal string "refused" (`ProcessManager::REFUSED`, this
|
|
535
|
+
# language's own Trigger vocabulary), not invented text: a
|
|
536
|
+
# dispatch declined is exactly as real a cause of a state
|
|
537
|
+
# transition as an event announced, and the diagram states it
|
|
538
|
+
# exactly as verbatim as every other edge here does.
|
|
539
|
+
def saga_diagram(bluebook, saga)
|
|
540
|
+
edges = saga.handlers.map { |handler| saga_edge(handler, saga) }
|
|
541
|
+
|
|
542
|
+
subject = "#{saga.hecks_name}'s own declared states and what each transition dispatches " \
|
|
543
|
+
"(starts on #{saga.starts_on}, ends on #{saga.ends_on})"
|
|
544
|
+
<<~MERMAID
|
|
545
|
+
#{header(bluebook.name, subject)}stateDiagram-v2
|
|
546
|
+
[*] --> #{saga.states.first}
|
|
547
|
+
#{edges.join("\n")}
|
|
548
|
+
MERMAID
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# THE REFUSED EDGE'S OWN DISPATCH LIST IS PARTLY DERIVED NOW —
|
|
552
|
+
# per-dispatch saga compensation (`compensates`) moved a saga's own
|
|
553
|
+
# compensating dispatches OFF the hand-written `on :refused` leg
|
|
554
|
+
# and onto whichever forward dispatch each one undoes, so
|
|
555
|
+
# `handler.dispatches` alone would render an EMPTY compensating
|
|
556
|
+
# edge for any saga using it — accurate to the DECLARATION, wrong
|
|
557
|
+
# about what the runtime actually does at refusal (it derives and
|
|
558
|
+
# fires every declared `compensates`, newest first). `saga` is
|
|
559
|
+
# passed through for exactly this — only the REFUSED handler needs
|
|
560
|
+
# it, every other edge's own `handler.dispatches` already says
|
|
561
|
+
# everything real about it.
|
|
562
|
+
def saga_edge(handler, saga)
|
|
563
|
+
label = handler.event_type
|
|
564
|
+
# DERIVED FIRST, then the hand-written body — the same order
|
|
565
|
+
# `SagaInterpreter#unwind` actually runs them in (every
|
|
566
|
+
# completed leg's own `compensates` before this leg's own
|
|
567
|
+
# hand-written dispatches), not declaration order on the page.
|
|
568
|
+
dispatched = handler.event_type == Bluebook::ProcessManager::REFUSED ? derived_compensations(saga) : []
|
|
569
|
+
dispatched += handler.dispatches.map(&:command_name)
|
|
570
|
+
label += " / dispatches #{dispatched.join(', ')}" unless dispatched.empty?
|
|
571
|
+
|
|
572
|
+
" #{handler.from_state} --> #{handler.to_state}: #{label}"
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Every `compensates` any forward dispatch in this saga declares,
|
|
576
|
+
# declaration order — the same commands `SagaInterpreter#unwind`
|
|
577
|
+
# derives and fires (newest-first, at actual refusal time; this
|
|
578
|
+
# diagram states them in declaration order, since it draws the
|
|
579
|
+
# saga's own shape, not one instance's own runtime history).
|
|
580
|
+
def derived_compensations(saga)
|
|
581
|
+
saga.handlers.flat_map { |handler| handler.dispatches.filter_map { |dispatch| dispatch.compensates&.command_name } }
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# ── frameworks -> flowchart ─────────────────────────────────────
|
|
585
|
+
|
|
586
|
+
# EVERY OTHER DIAGRAM IN THIS FILE STAYS INSIDE ONE DOMAIN'S OWN
|
|
587
|
+
# BOUNDARY — this is the one that steps outside it. A real domain
|
|
588
|
+
# depends on another domain's own aggregates in exactly two ways:
|
|
589
|
+
# `uses_framework "X"` in its `.hecksagon` (`Hecksagon#framework_
|
|
590
|
+
# members`), which loads X's whole bluebook into THIS registry,
|
|
591
|
+
# unconditionally, the moment this domain boots; or a policy's own
|
|
592
|
+
# `across "X"` (`Policy#target_domain`), which only reaches X when
|
|
593
|
+
# the policy's declared event actually fires. Same underlying
|
|
594
|
+
# fact `dispatch.mmd`'s own `trigger_edge` already draws from the
|
|
595
|
+
# command's side ("triggers in X") — this draws it again from the
|
|
596
|
+
# DOMAIN's side, next to the structural `uses_framework` fact
|
|
597
|
+
# `dispatch.mmd` never sees at all (that lives in the `.hecksagon`,
|
|
598
|
+
# which no other diagram here is handed).
|
|
599
|
+
#
|
|
600
|
+
# NEITHER THIS DOMAIN NOR EACH DEPENDENCY GETS THE holders() TREATMENT
|
|
601
|
+
# — a whole domain is drawn as ONE cylinder, the same "a bounded,
|
|
602
|
+
# addressable thing" shape every other diagram here already spends
|
|
603
|
+
# on a single aggregate, just scaled up one level: a domain is a
|
|
604
|
+
# bigger box the same kind of box lives inside.
|
|
605
|
+
#
|
|
606
|
+
# DOTTED FOR `attaches`, SOLID FOR `reaches across` — the reverse
|
|
607
|
+
# of which fact is "always true" between the two: attaching a
|
|
608
|
+
# framework is a standing declaration, true every time this domain
|
|
609
|
+
# boots, so it gets the same dotted "this always belongs" treatment
|
|
610
|
+
# `ports.mmd` gives an aggregate's own `-.->|exposes|` edge.
|
|
611
|
+
# Reaching across only happens when a real policy actually fires —
|
|
612
|
+
# the same solid edge `dispatch.mmd`'s own `trigger_edge` already
|
|
613
|
+
# draws for the identical fact, kept solid here so the same
|
|
614
|
+
# relationship reads the same way in both diagrams.
|
|
615
|
+
#
|
|
616
|
+
# `options[:hecksagon]` IS THE ONE DIAGRAM IN THIS FILE THAT NEEDS
|
|
617
|
+
# MORE THAN `bluebook` — `framework_members` lives on the
|
|
618
|
+
# `Hecksagon`, a sibling IR object `bin/project_diagrams` already
|
|
619
|
+
# has in hand (`registry.hecksagon(chapter_name)`) but `bluebook`
|
|
620
|
+
# itself carries no reference to. No hecksagon handed in (an older
|
|
621
|
+
# caller, or a spec that doesn't care) just means no frameworks.mmd
|
|
622
|
+
# — same "nothing to state" skip every other diagram here already
|
|
623
|
+
# takes when its own underlying data is empty.
|
|
624
|
+
def frameworks_diagram(bluebook, hecksagon)
|
|
625
|
+
return nil unless hecksagon
|
|
626
|
+
|
|
627
|
+
lines = hecksagon.framework_members.map { |name| domain_edge(bluebook.name, "attaches", name, dotted: true) }
|
|
628
|
+
lines += bluebook.policies.filter_map(&:target_domain).uniq
|
|
629
|
+
.map { |name| domain_edge(bluebook.name, "reaches across", name, dotted: false) }
|
|
630
|
+
return nil if lines.empty?
|
|
631
|
+
|
|
632
|
+
subject = "#{bluebook.name}'s own declared uses_framework and cross-domain policy targets"
|
|
633
|
+
"#{header(bluebook.name, subject)}flowchart LR\n#{lines.uniq.join("\n")}\n"
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def domain_edge(from, label, to, dotted:)
|
|
637
|
+
arrow = dotted ? "-.->" : "-->"
|
|
638
|
+
%( #{from}[(#{from})] #{arrow}|#{label}| #{to}[(#{to})])
|
|
639
|
+
end
|
|
411
640
|
end
|
|
412
641
|
end
|
|
413
642
|
end
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
require_relative "../
|
|
1
|
+
require_relative "../ports/persistence"
|
|
2
2
|
require_relative "../projector"
|
|
3
3
|
|
|
4
4
|
module Hecks
|
|
@@ -25,7 +25,20 @@ module Hecks
|
|
|
25
25
|
|
|
26
26
|
module_function
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
# ADR 0033 — `Runtime::StorageShape` lives in the era persistence
|
|
29
|
+
# plugin now; the `:shape` projection itself stays registered
|
|
30
|
+
# unconditionally (the seam `spec/projector_seam_spec.rb` enforces
|
|
31
|
+
# is "every file here registers," not "every projection always
|
|
32
|
+
# succeeds") but refuses clearly if asked to run with the plugin
|
|
33
|
+
# unloaded, rather than raising on an undefined constant.
|
|
34
|
+
def call(bluebook:, options: {})
|
|
35
|
+
unless Ports::Persistence.plugin?(:era)
|
|
36
|
+
raise "the :shape projection needs the era persistence plugin loaded " \
|
|
37
|
+
"(require \"hecks/ports/persistence/plugins/era\")"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
Runtime::StorageShape.project(bluebook)
|
|
41
|
+
end
|
|
29
42
|
end
|
|
30
43
|
end
|
|
31
44
|
end
|
|
@@ -192,7 +192,8 @@ module Hecks
|
|
|
192
192
|
end
|
|
193
193
|
|
|
194
194
|
{ verb: fqn(bluebook, aggregate, command, entity), kind: :command,
|
|
195
|
-
summary: command.goal, role: command.role,
|
|
195
|
+
summary: command.goal, role: command.role, role_gated: !command.role.to_s.empty?,
|
|
196
|
+
creates: command.creates?,
|
|
196
197
|
receiver: receiver, legacy_receiver: (receiver == :aggregate ? :id : nil),
|
|
197
198
|
legacy_arguments: legacy_arguments,
|
|
198
199
|
refusals: refusals(command, holder), arguments: arguments }
|
|
@@ -227,7 +228,15 @@ module Hecks
|
|
|
227
228
|
# names it short, which is the same split `shorten` already makes.
|
|
228
229
|
{ verb: [fqn(bluebook, aggregate, operation).sub(/\.[^.]+\z/, ""), port.name, operation.hecks_name].join("."),
|
|
229
230
|
kind: :command, creates: false, receiver: :aggregate, refusals: [],
|
|
231
|
+
# `role:` HERE IS DESCRIPTIVE TEXT, NOT AN AUTHORIZATION GATE —
|
|
232
|
+
# who calls whom through the port, for `--help`/`verb_help`'s
|
|
233
|
+
# "issued by" line. A port operation never reaches
|
|
234
|
+
# `CommandRules::Authorization#refuse_role_mismatch` (only
|
|
235
|
+
# `CommandInterpreter`/`EntityInterpreter` call it, never the port
|
|
236
|
+
# dispatch path), so `role_gated: false` always, unlike
|
|
237
|
+
# `command_spec` where the same key name means a real one.
|
|
230
238
|
role: operation.outbound? ? "#{aggregate.hecks_name} asking #{port.name}" : "#{port.name} telling #{aggregate.hecks_name}",
|
|
239
|
+
role_gated: false,
|
|
231
240
|
summary: port_summary(port, operation), arguments: arguments }
|
|
232
241
|
end
|
|
233
242
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
require "json"
|
|
2
|
-
require_relative "../
|
|
3
|
-
require_relative "../translation/rule_compiler"
|
|
2
|
+
require_relative "../ports/persistence"
|
|
4
3
|
|
|
5
4
|
module Hecks
|
|
6
5
|
module Projector
|
|
@@ -30,7 +29,15 @@ module Hecks
|
|
|
30
29
|
# Reuses `Runtime::EraCheck`'s own capability predicates rather than
|
|
31
30
|
# re-deriving them — the boot-time gate and this export must never
|
|
32
31
|
# answer differently for the same aggregate.
|
|
32
|
+
#
|
|
33
|
+
# ADR 0033 — `Runtime::EraCheck` lives in the (optional) era
|
|
34
|
+
# persistence plugin now; unloaded, this answers exactly what it
|
|
35
|
+
# already answers for a domain with nothing lineage-capable bound —
|
|
36
|
+
# `capable_aggregates: []` — rather than raising on an undefined
|
|
37
|
+
# constant.
|
|
33
38
|
def lineage(registry, domain_name)
|
|
39
|
+
return { capable_aggregates: [] } unless Ports::Persistence.plugin?(:era)
|
|
40
|
+
|
|
34
41
|
bluebook = registry.bluebooks.fetch(domain_name)
|
|
35
42
|
capable = bluebook.aggregates.select do |aggregate|
|
|
36
43
|
adapter_name = Runtime::EraCheck.adapter_for(registry, domain_name, aggregate)
|
|
@@ -130,7 +137,17 @@ module Hecks
|
|
|
130
137
|
# expression` is nil unless this edge rekeys — the bare
|
|
131
138
|
# `aggregate_id` passthrough head_compiler.rb itself falls back to
|
|
132
139
|
# for the overwhelming common case.
|
|
140
|
+
# ADR 0033 — `Translation::RuleCompiler` lives in the era plugin;
|
|
141
|
+
# unloaded, there is nothing that can compile this SQL, so this
|
|
142
|
+
# falls back to the bare declared fields (`translation_aggregate`
|
|
143
|
+
# alone) rather than raising on an undefined constant. A consumer
|
|
144
|
+
# embedding this JSON without the era plugin loaded gets the same
|
|
145
|
+
# declared-rules shape, just without precompiled SQL to execute —
|
|
146
|
+
# consistent with there being no mint/audit machinery to run it
|
|
147
|
+
# against either.
|
|
133
148
|
def compiled_translation_aggregate(aggregate)
|
|
149
|
+
return translation_aggregate(aggregate) unless Ports::Persistence.plugin?(:era)
|
|
150
|
+
|
|
134
151
|
translation_aggregate(aggregate).merge(
|
|
135
152
|
compiled_state_expression: Translation::RuleCompiler.compile_rules(aggregate),
|
|
136
153
|
compiled_id_expression: Translation::RuleCompiler.rekeyed?(aggregate) ? Translation::RuleCompiler.compile_id_expression(aggregate) : nil
|
data/lib/hecks/query_ir.rb
CHANGED
|
@@ -233,6 +233,25 @@ module Hecks
|
|
|
233
233
|
# hand whether this is ALREADY a chapter-wide reference before
|
|
234
234
|
# assuming it's fresh duplication," not as an automatic signal
|
|
235
235
|
# either way.
|
|
236
|
+
#
|
|
237
|
+
# THE IDENTICAL GAP, ONE LEVEL DOWN: chapter-wide ENTITY-scoped
|
|
238
|
+
# sharing (`EntityBuilder#given`'s own bare form,
|
|
239
|
+
# `docs/implemented/resolution-rules/chapter-entity-given.md`) hits this same wall for
|
|
240
|
+
# the same structural reason — `SafeDepositBox.Visit` still shows
|
|
241
|
+
# as its own "(declared)" owner here even after becoming a bare
|
|
242
|
+
# reference to `Account.LedgerEntry`'s declaration, because a piece
|
|
243
|
+
# resolving a chapter-wide reference still write-throughs the
|
|
244
|
+
# resolved `Given` into its own `@named_givens` (so ITS OWN
|
|
245
|
+
# commands can read it back locally without a second hop). This is
|
|
246
|
+
# not a NEW limitation this feature introduces — it is the exact
|
|
247
|
+
# same IR-cannot-distinguish-declared-from-referenced fact, one
|
|
248
|
+
# scope wider. `bin/query_ir duplicates` confirms this directly:
|
|
249
|
+
# `Account.LedgerEntry (declared)` and `SafeDepositBox.Visit
|
|
250
|
+
# (declared)` both appear under the same `given: "customer is
|
|
251
|
+
# active"` group — verify by hand, same as the aggregate-level
|
|
252
|
+
# case above, before assuming a group naming two pieces under
|
|
253
|
+
# different aggregates is fresh duplication rather than an already-
|
|
254
|
+
# resolved chapter-wide reference.
|
|
236
255
|
def declaration_count(rules)
|
|
237
256
|
declared = rules.select { |r| r.location.end_with?(" (declared)") }
|
|
238
257
|
declared_owners = declared.map { |r| owner_of(r.location) }.to_set
|
|
@@ -25,12 +25,24 @@ module Hecks
|
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# M3 — an UNDECLARED (`native`) null policy used to render no
|
|
29
|
+
# `NULLS ...` clause at all here, leaving each dialect's own
|
|
30
|
+
# default to decide: Postgres puts nulls LAST on ASC (and FIRST
|
|
31
|
+
# on DESC), while `#order` above — this same "native" default,
|
|
32
|
+
# for Memory — puts nulls FIRST on ASC (and LAST on DESC), the
|
|
33
|
+
# SQLite convention. Same query, same data, different row order
|
|
34
|
+
# depending only on which adapter ran it. Rendered explicitly
|
|
35
|
+
# here instead, so an undeclared policy means the SAME total
|
|
36
|
+
# order everywhere rather than "whatever this store already does"
|
|
37
|
+
# — matching `#order`'s own default rather than the other way
|
|
38
|
+
# round, since that default is unconditional (Memory/Heki have no
|
|
39
|
+
# dialect to defer to) and SQLite already agrees with it natively.
|
|
28
40
|
def sql_order(expression, direction, policy)
|
|
29
41
|
direction = direction.to_s.downcase == "desc" ? "DESC" : "ASC"
|
|
30
42
|
nulls = case policy&.mode.to_s
|
|
31
43
|
when "first" then " NULLS FIRST"
|
|
32
44
|
when "last" then " NULLS LAST"
|
|
33
|
-
else ""
|
|
45
|
+
else direction == "DESC" ? " NULLS LAST" : " NULLS FIRST"
|
|
34
46
|
end
|
|
35
47
|
"#{expression} #{direction}#{nulls}, id #{direction}"
|
|
36
48
|
end
|
|
@@ -31,7 +31,11 @@ module Hecks
|
|
|
31
31
|
# plain row hash); the rest read through whatever each step holds. A
|
|
32
32
|
# stored nested value object is a plain hash by the time it is read
|
|
33
33
|
# back, keyed by symbol in memory and by string off a wire decode,
|
|
34
|
-
# so both spellings are tried
|
|
34
|
+
# so both spellings are tried — `key?` first, never `||`, because
|
|
35
|
+
# `||` falls through a genuinely-stored `false` to the OTHER
|
|
36
|
+
# spelling (usually absent) and returns `nil` instead. The seal
|
|
37
|
+
# admits boolean leaves (`SCALAR_PRIMITIVES` below), so a `false`
|
|
38
|
+
# here is a real, held answer, not a missing one.
|
|
35
39
|
def dig(holder, field)
|
|
36
40
|
return nil if field.nil?
|
|
37
41
|
|
|
@@ -40,7 +44,21 @@ module Hecks
|
|
|
40
44
|
|
|
41
45
|
def read(current, segment)
|
|
42
46
|
return nil if current.nil?
|
|
43
|
-
|
|
47
|
+
|
|
48
|
+
if current.is_a?(Hash)
|
|
49
|
+
sym = segment.to_sym
|
|
50
|
+
return current.key?(sym) ? current[sym] : current[segment]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# M5 — "or nil, never raise" is this method's whole contract, and
|
|
54
|
+
# an Array broke it: `Array#[]` demands an Integer index, so
|
|
55
|
+
# `current[segment]` (a String) raised `TypeError` straight
|
|
56
|
+
# through `dig` instead of answering nil. A dotted path stepping
|
|
57
|
+
# INTO a list_of attribute (`where "tags.name" == "x"` against a
|
|
58
|
+
# bare list, rather than each element) has no single member a
|
|
59
|
+
# bare index would name anyway — nil is the honest answer, the
|
|
60
|
+
# same one a dangling reference or a missing key already gets.
|
|
61
|
+
return nil if current.is_a?(Array)
|
|
44
62
|
|
|
45
63
|
current[segment]
|
|
46
64
|
end
|
|
@@ -7,11 +7,13 @@ module Hecks
|
|
|
7
7
|
# sibling, not its member. FieldPath walks a SHAPE, which cannot
|
|
8
8
|
# loop, and answers nil, never raising, because there is nothing
|
|
9
9
|
# left to say beyond "not found." HopPath walks the REFERENCE
|
|
10
|
-
# GRAPH instead
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
10
|
+
# GRAPH instead — `BluebookBuilder#validate_no_bidirectional_references!`
|
|
11
|
+
# refuses any reference CYCLE at declaration time (ADR 0025,
|
|
12
|
+
# "References" — widened from a direct pair to any ring, DFS over
|
|
13
|
+
# the chapter's own reference graph), so what's left to guard here
|
|
14
|
+
# is depth, not cycles: MAX_HOPS below, and this module's own
|
|
15
|
+
# `refusal` states for a target the chapter doesn't resolve. A walk
|
|
16
|
+
# here still needs to say WHY it stopped, not just that it did.
|
|
15
17
|
#
|
|
16
18
|
# Every method below takes an ATTRIBUTE ARRAY, never a "shape"
|
|
17
19
|
# object — deliberately, because the two real callers hold their
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Runtime
|
|
3
|
+
# A PROCESS-WIDE, STRIPED MUTEX REGISTRY — the concurrency-control
|
|
4
|
+
# mechanism for every adapter that does NOT declare
|
|
5
|
+
# `:optimistic_concurrency` (Heki, Memory today; see
|
|
6
|
+
# `CommandInterpreter#call`/`EntityInterpreter#call`, which choose
|
|
7
|
+
# between this and Postgres's CAS+retry purely off
|
|
8
|
+
# `repository.capabilities`).
|
|
9
|
+
#
|
|
10
|
+
# WHY A LOCK SUFFICES HERE AND CAS IS NOT NEEDED: both adapters hold
|
|
11
|
+
# process-local data. `Adapters::Memory.tenant_capable?`'s own comment
|
|
12
|
+
# states the confirmed fact this relies on — two `Runtime.boot` calls
|
|
13
|
+
# get two entirely separate adapter instances; there is never a SECOND
|
|
14
|
+
# PROCESS writing the same Heki file or the same Memory Hash, only
|
|
15
|
+
# possibly other THREADS within this one process. A `Mutex` held for
|
|
16
|
+
# the full hydrate-through-save critical section closes the identical
|
|
17
|
+
# lost-update gap CAS closes for Postgres, with no schema, no version
|
|
18
|
+
# column, and no retry loop — the second thread simply doesn't start
|
|
19
|
+
# its own hydrate until the first thread's save has landed.
|
|
20
|
+
#
|
|
21
|
+
# STRIPED, NOT ONE GLOBAL LOCK: keyed by `[domain, aggregate.hecks_name,
|
|
22
|
+
# id]`, so two dispatches against two DIFFERENT records never block
|
|
23
|
+
# each other. The registry Hash itself is guarded by its own top-level
|
|
24
|
+
# Mutex only for the moment a new per-key Mutex is created — two
|
|
25
|
+
# threads locking DIFFERENT keys for the first time never wait on one
|
|
26
|
+
# another beyond that brief creation window.
|
|
27
|
+
module AggregateLock
|
|
28
|
+
@registry_lock = Mutex.new
|
|
29
|
+
@locks = {}
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# `AggregateLock.for(domain, aggregate, id).synchronize { ... }`
|
|
33
|
+
# `id: nil` — identity could not be resolved yet (see
|
|
34
|
+
# `Identity.best_effort`) — locks by aggregate TYPE alone, coarser
|
|
35
|
+
# (every record of this aggregate serializes against every other)
|
|
36
|
+
# but still correct: it can only ever make dispatch MORE
|
|
37
|
+
# conservative than a resolved id would.
|
|
38
|
+
def for(domain, aggregate, id = nil)
|
|
39
|
+
key = id.nil? ? [domain.to_s, aggregate.hecks_name] : [domain.to_s, aggregate.hecks_name, id.to_s]
|
|
40
|
+
@registry_lock.synchronize { @locks[key] ||= Mutex.new }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Runtime
|
|
3
|
+
# A boot's own small set of phase-tagged, conditionally-present gates —
|
|
4
|
+
# ADR 0031. Registration is instance-scoped, ONE PER `Loader.boot`
|
|
5
|
+
# CALL, never a module-level singleton: a process that boots more than
|
|
6
|
+
# one registry in its lifetime (every spec suite does) must never let
|
|
7
|
+
# one boot's capability profile leak into the next boot's gate list.
|
|
8
|
+
#
|
|
9
|
+
# A gate is anything `.call(registry, directory)`-able — an existing
|
|
10
|
+
# module method handed over as a `Method` object (`EraCheck.method
|
|
11
|
+
# (:check_lineage!)`) needs no wrapper; a bare block does. Phases run
|
|
12
|
+
# in the order `run!` is called, gates within a phase in registration
|
|
13
|
+
# order — today exactly one gate per phase, so ordering among
|
|
14
|
+
# same-phase gates has never been exercised.
|
|
15
|
+
#
|
|
16
|
+
# This is deliberately NOT the same registry `Hecks::Projector` uses
|
|
17
|
+
# (ADR 0027) — that one is a process-wide, static IR-in/artifact-out
|
|
18
|
+
# registry with no bindings and no live-state mutation; this one is
|
|
19
|
+
# per-boot and gates real I/O (a Postgres mint, a saga-store read).
|
|
20
|
+
# Sharing a primitive between them is deferred until a third consumer
|
|
21
|
+
# actually wants it (0031's own Rejected Alternatives).
|
|
22
|
+
class BootGates
|
|
23
|
+
def initialize
|
|
24
|
+
@gates = Hash.new { |h, k| h[k] = [] }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def register(name, gate, phase:)
|
|
28
|
+
@gates[phase] << [name, gate]
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def registered?(name)
|
|
33
|
+
@gates.values.flatten(1).any? { |registered_name, _gate| registered_name == name }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run!(phase, registry, directory)
|
|
37
|
+
@gates[phase].each { |pair| pair.last.call(registry, directory) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/hecks/runtime/caller.rb
CHANGED
|
@@ -19,15 +19,31 @@ module Hecks
|
|
|
19
19
|
# `CommandRules::Authorization` check a real Governance
|
|
20
20
|
# `RoleAssignment` instead, once the command's domain has Governance
|
|
21
21
|
# attached — see that module's own header for the full split.
|
|
22
|
-
|
|
22
|
+
#
|
|
23
|
+
# `as_of` and `scope` are BOTH optional too, and both self-asserted
|
|
24
|
+
# by the caller rather than derived from the command — deliberately:
|
|
25
|
+
# `as_of` is filled at the door from `Ports::Clock.now`, never
|
|
26
|
+
# inside the interpreter (see `Ports::Clock`'s own header), so
|
|
27
|
+
# there is no other place to source it from. `scope` stays here
|
|
28
|
+
# rather than becoming a command-level DSL construct on purpose —
|
|
29
|
+
# a scope check that lived in the bluebook would put an
|
|
30
|
+
# authorization concern inside the domain declaration itself; this
|
|
31
|
+
# keeps it an application-boundary fact instead, the same shape
|
|
32
|
+
# `role`/`actor_id` already are. The tradeoff: a caller states what
|
|
33
|
+
# scope it is acting in, and `holds_role?` verifies a live grant
|
|
34
|
+
# exists for THAT scope — it does not independently confirm the
|
|
35
|
+
# scope matches whatever the command's own target data belongs to.
|
|
36
|
+
Current = Struct.new(:role, :actor_id, :as_of, :scope, keyword_init: true)
|
|
23
37
|
|
|
24
38
|
module_function
|
|
25
39
|
|
|
26
40
|
def current = Thread.current[:hecks_caller]
|
|
27
41
|
|
|
28
|
-
def as(role:, actor_id: nil)
|
|
42
|
+
def as(role:, actor_id: nil, as_of: nil, scope: nil)
|
|
29
43
|
previous = Thread.current[:hecks_caller]
|
|
30
|
-
Thread.current[:hecks_caller] = Current.new(
|
|
44
|
+
Thread.current[:hecks_caller] = Current.new(
|
|
45
|
+
role: role.to_s, actor_id: actor_id&.to_s, as_of: as_of, scope: scope&.to_s
|
|
46
|
+
)
|
|
31
47
|
yield
|
|
32
48
|
ensure
|
|
33
49
|
Thread.current[:hecks_caller] = previous
|
|
@@ -19,8 +19,19 @@ module Hecks
|
|
|
19
19
|
# the aggregate rather than describe it : `id`, whatever the aggregate is
|
|
20
20
|
# identified by, and the reference key of the root a command reaches
|
|
21
21
|
# through. Refusing those would refuse every dispatch there is.
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
#
|
|
23
|
+
# `extra_identity_heads:` — EntityInterpreter's own callers only. An
|
|
24
|
+
# entity dispatch addresses not just the root aggregate but every
|
|
25
|
+
# entity ALONG THE CHAIN it walks to reach the piece the command
|
|
26
|
+
# actually belongs to (`Handler.Dispatch.Bind` is two hops), and each
|
|
27
|
+
# hop's own `identity_heads` is addressing the same way the root's
|
|
28
|
+
# are — `element_of` (entity_element.rb) reads them straight out of
|
|
29
|
+
# `args` to find the element, never as a fact the command itself
|
|
30
|
+
# declares. `[]` for a plain aggregate/port-operation dispatch, which
|
|
31
|
+
# has no chain to add.
|
|
32
|
+
def refuse_unknown_arguments(domain, aggregate, command, args, extra_identity_heads: [])
|
|
33
|
+
addressing = [:id, *aggregate.identity_heads, *extra_identity_heads, reference_key(command)] +
|
|
34
|
+
correlation_keys(domain)
|
|
24
35
|
known = (command.attributes.map(&:name) + addressing).compact.map(&:to_sym)
|
|
25
36
|
# SORTED. Payload order is whatever the caller happened to write, and
|
|
26
37
|
# refusal wording is contract — pinned byte-for-byte by the corpus, so
|
|
@@ -71,6 +71,18 @@ module Hecks
|
|
|
71
71
|
# never through this method.
|
|
72
72
|
when :delegate
|
|
73
73
|
nil
|
|
74
|
+
# `corrects` — CommandBuilder#corrects_impl's own comment gives
|
|
75
|
+
# the full reasoning for storing it as a mutation at all. A REAL
|
|
76
|
+
# no-op here too: it targets no field on THIS instance either —
|
|
77
|
+
# its own event name, and whether THIS record has actually
|
|
78
|
+
# emitted it, is checked once, up front, by
|
|
79
|
+
# CommandRules::Admissibility#enforce_correction_target, not
|
|
80
|
+
# here. Whatever field this correction actually changes is an
|
|
81
|
+
# ORDINARY declared (or, for `reverses: true`, derived — see
|
|
82
|
+
# AggregateBuilder#seal_correction_targets) mutation of its own,
|
|
83
|
+
# applied by one of the branches above like any other.
|
|
84
|
+
when :corrects
|
|
85
|
+
nil
|
|
74
86
|
else
|
|
75
87
|
# Every declared op has a `when` above — this is not a real
|
|
76
88
|
# runtime path today, only a backstop against the day one
|