hecks 1.0.0 → 1.0.2
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/in_memory_ordering.rb +9 -1
- data/lib/hecks/adapters/driven/sql_query_builder.rb +13 -2
- data/lib/hecks/bluebook/behaviour/value_object.rb +13 -7
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +30 -1
- data/lib/hecks/bluebook/dsl/command_builder.rb +53 -0
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +49 -7
- data/lib/hecks/bluebook/expression/ast_json.rb +149 -0
- data/lib/hecks/bluebook/expression/resolver.rb +25 -0
- data/lib/hecks/bluebook/expression.rb +1 -0
- data/lib/hecks/bluebook/model_check.rb +15 -1
- data/lib/hecks/bluebook/project_register.rb +25 -0
- data/lib/hecks/bluebook/value_object.rb +10 -1
- data/lib/hecks/fuzzing/invalid_value_generator.rb +3 -2
- data/lib/hecks/fuzzing/properties.rb +49 -4
- data/lib/hecks/language/bluebook/aggregate.bluebook +17 -0
- data/lib/hecks/language/hecksagon/domain_port.bluebook +25 -1
- data/lib/hecks/runtime/command_interpreter.rb +70 -23
- data/lib/hecks/runtime/command_rules/admissibility.rb +42 -7
- data/lib/hecks/runtime/command_rules/arithmetic.rb +33 -3
- data/lib/hecks/runtime/dependency_planning.rb +52 -5
- data/lib/hecks/runtime/entity_interpreter.rb +8 -1
- data/lib/hecks/runtime/read_model_interpreter.rb +13 -4
- data/lib/hecks/runtime/value.rb +54 -4
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +21 -1
- metadata +4 -1
|
@@ -321,7 +321,8 @@ module Hecks
|
|
|
321
321
|
|
|
322
322
|
domain, aggregate_name, = Naming.split_verb(asked[:query])
|
|
323
323
|
args = asked[:args] || {}
|
|
324
|
-
rows = query_eligible_rows(asked.fetch(:instances_at), domain, aggregate_name, declared.wheres, args
|
|
324
|
+
rows = query_eligible_rows(asked.fetch(:instances_at), domain, aggregate_name, declared.wheres, args,
|
|
325
|
+
bluebooks: bluebooks)
|
|
325
326
|
ordered = Ports::Query::Ordering.apply(
|
|
326
327
|
rows, declared.order_by, declared.null_semantics, identity: ->(row) { row[:id].to_s }
|
|
327
328
|
) { |row| Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, declared.order_by.field)) }
|
|
@@ -361,21 +362,65 @@ module Hecks
|
|
|
361
362
|
# same way #eligible_rows' own rows are, since a stable sort
|
|
362
363
|
# (Ordering.apply's own `identity:`) and the real answer's own
|
|
363
364
|
# `record.state.merge(id: record.id)` both need it.
|
|
364
|
-
|
|
365
|
+
# `bluebooks:` — needed ONLY to recognise and resolve a `/` HOP
|
|
366
|
+
# clause (`engagement/client/status`, hop_chain.bluebook's own
|
|
367
|
+
# PricedAboveViaEngagement): a hop's head names one of the OWNING
|
|
368
|
+
# aggregate's declared references, and only the declaration graph
|
|
369
|
+
# can say which attribute that is and which aggregate it targets.
|
|
370
|
+
# A local clause never consults it. Latent gap this closed, found
|
|
371
|
+
# by the fuzzer itself the first time a generated sequence ever
|
|
372
|
+
# built a full hop chain AND had its paged query answer a row
|
|
373
|
+
# (seed 1, the moment scalar_value_objects.bluebook joined the
|
|
374
|
+
# fixtures corpus and shifted every seeded draw): the recompute
|
|
375
|
+
# dug `engagement/client/status` as a LOCAL dotted path, found
|
|
376
|
+
# nil, and declared every genuinely-eligible row ineligible — a
|
|
377
|
+
# false property violation against a correct runtime answer,
|
|
378
|
+
# reproducible on an untouched main with this same 4-step script.
|
|
379
|
+
def query_eligible_rows(instances, domain, aggregate_name, wheres, args, bluebooks: {})
|
|
380
|
+
aggregate = bluebooks[domain]&.aggregate(aggregate_name)
|
|
365
381
|
prefix = "#{domain}::#{aggregate_name}#"
|
|
366
382
|
instances.filter_map do |key, state|
|
|
367
383
|
next unless key.start_with?(prefix)
|
|
368
384
|
|
|
369
385
|
row = state.merge(id: key.split("#").last)
|
|
370
386
|
next unless wheres.all? do |clause|
|
|
371
|
-
|
|
372
|
-
Ports::Query::InMemory.
|
|
387
|
+
resolved = resolve_hop_clause(instances, domain, aggregate, clause, args, bluebooks)
|
|
388
|
+
held = Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, resolved.field))
|
|
389
|
+
Ports::Query::InMemory.holds?(resolved, held, args)
|
|
373
390
|
end
|
|
374
391
|
|
|
375
392
|
row
|
|
376
393
|
end
|
|
377
394
|
end
|
|
378
395
|
|
|
396
|
+
# `Runtime::ReferenceHop#fold`, independently restated over the
|
|
397
|
+
# replay's own `:instances_at` snapshot instead of live
|
|
398
|
+
# repositories — the same shape every other recompute in this
|
|
399
|
+
# file takes (never the runtime's own code path, or the property
|
|
400
|
+
# would be checking the runtime against itself). One hop peels
|
|
401
|
+
# off the head (`HopPath.next_hop`, the identical one-step
|
|
402
|
+
# primitive the live fold uses), the inner clause recurses
|
|
403
|
+
# through `query_eligible_rows` against the TARGET's own
|
|
404
|
+
# snapshot rows (so a multi-hop tail resolves hop by hop, exactly
|
|
405
|
+
# as the live path's own recursion does), and the ids that
|
|
406
|
+
# answered fold back as the same local `in` membership clause the
|
|
407
|
+
# live fold builds. A clause with no `/`, or one whose head this
|
|
408
|
+
# aggregate's declarations cannot resolve, passes through
|
|
409
|
+
# untouched and evaluates locally as it always did.
|
|
410
|
+
def resolve_hop_clause(instances, domain, aggregate, clause, args, bluebooks)
|
|
411
|
+
return clause unless aggregate && QuerySpecification::HopPath.hop_head?(clause.field, aggregate.attributes)
|
|
412
|
+
|
|
413
|
+
hop, rest = QuerySpecification::HopPath.next_hop(clause.field, aggregate.attributes)
|
|
414
|
+
target = hop.target
|
|
415
|
+
return clause unless target
|
|
416
|
+
|
|
417
|
+
inner = QuerySpecification::Common::WhereClause.new(field: rest, op: clause.op, value: clause.value)
|
|
418
|
+
ids = query_eligible_rows(instances, domain, target.hecks_name, [inner], args, bluebooks: bluebooks)
|
|
419
|
+
.map { |row| row[:id].to_s }.uniq
|
|
420
|
+
|
|
421
|
+
QuerySpecification::Common::WhereClause.new(field: hop.attribute.name, op: "in", value: ids)
|
|
422
|
+
end
|
|
423
|
+
|
|
379
424
|
# `QueryInterpreter#resolve_query_value`, reproduced: a declared
|
|
380
425
|
# limit/offset is either a literal or a Symbol naming an argument
|
|
381
426
|
# the caller supplied.
|
|
@@ -509,6 +509,15 @@ Hecks.bluebook "Bluebook" do
|
|
|
509
509
|
member word: "query", context: "Aggregate", body: "keywords", inner: "Query", opens: "Query", fills: "", calls: "query_impl"
|
|
510
510
|
member word: "policy", context: "Aggregate", body: "keywords", inner: "Policy", opens: "Policy", fills: "", calls: "policy_impl"
|
|
511
511
|
member word: "value_object", context: "Aggregate", body: "keywords", inner: "ValueObject", opens: "ValueObject", fills: "value_objects"
|
|
512
|
+
# THE BARE SHORTHAND — `value_object "Price", Integer`, no block:
|
|
513
|
+
# a second row for the SAME word with `body: "none"`, the exact
|
|
514
|
+
# two-row move `identified_by` (above) already makes for its own
|
|
515
|
+
# block/blockless split — the body gate picks whichever row
|
|
516
|
+
# matches how the line was actually written. Declares a value
|
|
517
|
+
# object with exactly one attribute, named `value`, of the given
|
|
518
|
+
# type; sugar for the block form's own single `attribute :value,
|
|
519
|
+
# Type` line (`AggregateBuilder#value_object`'s own comment).
|
|
520
|
+
member word: "value_object", context: "Aggregate", body: "none", inner: "", opens: "", fills: "value_objects"
|
|
512
521
|
member word: "command", context: "Aggregate", body: "keywords", inner: "Command", opens: "Command", fills: "", calls: "command_impl"
|
|
513
522
|
member word: "attribute", context: "Aggregate", body: "none", inner: "", opens: "", fills: "attributes", calls: "attribute_impl"
|
|
514
523
|
member word: "invariant", context: "Aggregate", body: "source", inner: "", opens: "", fills: "invariants", calls: "invariant_impl"
|
|
@@ -558,6 +567,14 @@ Hecks.bluebook "Bluebook" do
|
|
|
558
567
|
member keyword: "query", context: "Aggregate", at: "1", named: "", kind: "text", required: "true", fills: "name"
|
|
559
568
|
member keyword: "policy", context: "Aggregate", at: "1", named: "", kind: "text", required: "true", fills: "name"
|
|
560
569
|
member keyword: "value_object", context: "Aggregate", at: "1", named: "", kind: "text", required: "true", fills: "name"
|
|
570
|
+
# The bare shorthand's own TYPE argument — optional, because the
|
|
571
|
+
# block form gives no type at all (its attributes carry their own);
|
|
572
|
+
# `kind: "constant"` for the same reason `attribute`'s own type
|
|
573
|
+
# argument (below) is: a type is a bare constant, never quoted
|
|
574
|
+
# text. Type-AND-block is refused by the builder itself
|
|
575
|
+
# (`AggregateBuilder#value_object`), not here — an argument row
|
|
576
|
+
# cannot see the body, only the call.
|
|
577
|
+
member keyword: "value_object", context: "Aggregate", at: "2", named: "", kind: "constant", required: "false", fills: "type"
|
|
561
578
|
member keyword: "command", context: "Aggregate", at: "1", named: "", kind: "text", required: "true", fills: "name"
|
|
562
579
|
member keyword: "command", context: "Aggregate", at: "", named: "from", kind: "literal", required: "false", fills: "from"
|
|
563
580
|
member keyword: "attribute", context: "Aggregate", at: "1", named: "", kind: "symbol", required: "true", fills: "name"
|
|
@@ -4,7 +4,9 @@ Hecks.bluebook "Hecksagon" do
|
|
|
4
4
|
|
|
5
5
|
identified_by PortName, as: :name
|
|
6
6
|
|
|
7
|
-
attribute :verb,
|
|
7
|
+
attribute :verb, PortVerb, optional: true
|
|
8
|
+
attribute :signal, PortSignal, optional: true
|
|
9
|
+
attribute :answers, list_of(PortAnswer)
|
|
8
10
|
|
|
9
11
|
value_object "PortName" do
|
|
10
12
|
attribute :value, String
|
|
@@ -16,6 +18,24 @@ Hecks.bluebook "Hecksagon" do
|
|
|
16
18
|
invariant("a verb is named") { !value.to_s.empty? }
|
|
17
19
|
end
|
|
18
20
|
|
|
21
|
+
# SAME CLOSED SET port.bluebook's own "Port" aggregate declares —
|
|
22
|
+
# kept as its own local value object rather than shared cross-
|
|
23
|
+
# chapter, the same way PortVerb already is here.
|
|
24
|
+
value_object "PortSignal" do
|
|
25
|
+
attribute :value, String, one_of: ["reply", "effect"]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# `answers` — DomainPortBuilder's own twin of PortBuilder#answers
|
|
29
|
+
# (port.bluebook's "Port" aggregate, same shape), added once a real
|
|
30
|
+
# `.port` file migrated to parse through this builder (the repoint
|
|
31
|
+
# `lib/hecks.rb#port`'s own comment describes) used the word
|
|
32
|
+
# (`extraction.port`'s own `answers :canonical`). Local, not shared
|
|
33
|
+
# cross-chapter, same reasoning as PortSignal just above.
|
|
34
|
+
value_object "PortAnswer" do
|
|
35
|
+
attribute :value, String
|
|
36
|
+
invariant("an answer names a real method") { !value.to_s.empty? }
|
|
37
|
+
end
|
|
38
|
+
|
|
19
39
|
# HOW DOMAINPORT IS SPELLED. These rows live with the concept
|
|
20
40
|
# they describe; SyntaxBoot discovers every aggregate-local seed table.
|
|
21
41
|
# Contexts represented here: DomainPort.
|
|
@@ -35,6 +55,8 @@ Hecks.bluebook "Hecksagon" do
|
|
|
35
55
|
member word: "tells", context: "DomainPort", body: "keywords", inner: "PortOperation", opens: "PortOperation", fills: "", calls: "tells_impl"
|
|
36
56
|
member word: "asks", context: "DomainPort", body: "keywords", inner: "PortOperation", opens: "PortOperation", fills: "", calls: "asks_impl"
|
|
37
57
|
member word: "verb", context: "DomainPort", body: "none", inner: "", opens: "", fills: "verb"
|
|
58
|
+
member word: "signal", context: "DomainPort", body: "none", inner: "", opens: "", fills: "signal"
|
|
59
|
+
member word: "answers", context: "DomainPort", body: "none", inner: "", opens: "", fills: "answers"
|
|
38
60
|
|
|
39
61
|
end
|
|
40
62
|
|
|
@@ -59,6 +81,8 @@ Hecks.bluebook "Hecksagon" do
|
|
|
59
81
|
member keyword: "tells", context: "DomainPort", at: "1", named: "", kind: "text", required: "true", fills: "name"
|
|
60
82
|
member keyword: "asks", context: "DomainPort", at: "1", named: "", kind: "text", required: "true", fills: "name"
|
|
61
83
|
member keyword: "verb", context: "DomainPort", at: "1", named: "", kind: "text", required: "true", fills: "verb"
|
|
84
|
+
member keyword: "signal", context: "DomainPort", at: "1", named: "", kind: "symbol", required: "true", fills: "signal"
|
|
85
|
+
member keyword: "answers", context: "DomainPort", at: "1", named: "", kind: "symbol", required: "true", fills: "answers"
|
|
62
86
|
|
|
63
87
|
# THE RECEIVING AGGREGATE — PortOperationBuilder's own `reference_to`
|
|
64
88
|
# (inside the block) is refused (#335); this is the sanctioned
|
|
@@ -48,7 +48,7 @@ module Hecks
|
|
|
48
48
|
# that sets them runs, same as they were unset locals before that point.
|
|
49
49
|
Context = Struct.new(:domain, :aggregate, :command, :args, :repository, :instance, :transition, :old_state,
|
|
50
50
|
:result, :correlation, :route, :plan, :strategy, :persistence_outcome, :delegated_events,
|
|
51
|
-
:dry_run)
|
|
51
|
+
:dry_run, :correction_bindings)
|
|
52
52
|
|
|
53
53
|
def initialize(registry, rules:)
|
|
54
54
|
@registry = registry
|
|
@@ -135,10 +135,13 @@ module Hecks
|
|
|
135
135
|
# NotFound/AlreadyExists already get at hydration: "does the
|
|
136
136
|
# fact this command's corrects names even exist" is not a
|
|
137
137
|
# domain rule an author wrote, it is a precondition for the
|
|
138
|
-
# domain rules to mean anything at all.
|
|
139
|
-
|
|
138
|
+
# domain rules to mean anything at all. Also locates the
|
|
139
|
+
# correction target itself, if `as:` named one — carried on
|
|
140
|
+
# `ctx` so `step_enforce_ensures` (the settled-record half)
|
|
141
|
+
# can bind the SAME name too, not just this pre-mutation half.
|
|
142
|
+
ctx.correction_bindings = @rules.enforce_correction_target(ctx.instance, ctx.aggregate, ctx.command, domain: ctx.domain)
|
|
140
143
|
@rules.enforce_givens(ctx.instance, ctx.command, ctx.args, domain: ctx.domain,
|
|
141
|
-
declaring: ctx.aggregate, parent: ctx.instance)
|
|
144
|
+
declaring: ctx.aggregate, parent: ctx.instance, correction: ctx.correction_bindings)
|
|
142
145
|
}
|
|
143
146
|
end
|
|
144
147
|
|
|
@@ -239,7 +242,7 @@ module Hecks
|
|
|
239
242
|
def step_enforce_ensures(ctx)
|
|
240
243
|
step(:enforce_ensures) {
|
|
241
244
|
@rules.enforce_ensures(ctx.instance, ctx.command, ctx.args, old: ctx.old_state,
|
|
242
|
-
domain: ctx.domain, parent: ctx.instance)
|
|
245
|
+
domain: ctx.domain, parent: ctx.instance, correction: ctx.correction_bindings || {})
|
|
243
246
|
}
|
|
244
247
|
end
|
|
245
248
|
|
|
@@ -260,7 +263,7 @@ module Hecks
|
|
|
260
263
|
seed_projected_fields(ctx)
|
|
261
264
|
ctx.persistence_outcome = if ctx.strategy == DependencyPlanning::ATOMIC_PUT
|
|
262
265
|
# A SECOND CREATION IS NOT A FRESH ONE — see
|
|
263
|
-
#
|
|
266
|
+
# hydrate_complete_state's own comment; the
|
|
264
267
|
# same refusal, on the same terms, for the
|
|
265
268
|
# complete-state path. `insert_only:` asks the
|
|
266
269
|
# ADAPTER to decide and refuse ATOMICALLY
|
|
@@ -376,19 +379,61 @@ module Hecks
|
|
|
376
379
|
found.dup
|
|
377
380
|
end
|
|
378
381
|
|
|
379
|
-
#
|
|
380
|
-
#
|
|
381
|
-
#
|
|
382
|
-
#
|
|
383
|
-
#
|
|
382
|
+
# WAVE 8 (equivalence-gap plan, item 1.6) — NOT done, and this
|
|
383
|
+
# comment now says precisely how far it got, corrected from an
|
|
384
|
+
# EARLIER version of itself that briefly (same PR, never released)
|
|
385
|
+
# claimed the inventory was empty and deleted this method outright.
|
|
386
|
+
# A full corpus audit (`DependencyPlanning::Analyzer.call` against
|
|
387
|
+
# every `creates?`-true command in all 5 EXAMPLE domains — banking,
|
|
388
|
+
# pizzas, chess, compliance, roster) found and fixed 4 real
|
|
389
|
+
# authoring bugs (`Statement.Generate`, `CreatePizza`, `Chess::
|
|
390
|
+
# Game.Start`, `Roster.Open` — each declared an attribute and
|
|
391
|
+
# never `sets` it, so the field silently stayed nil on every
|
|
392
|
+
# created record regardless of what a caller sent), plus a real
|
|
393
|
+
# `DependencyPlanning::Analyzer` bug for ENTITY-owned commands
|
|
394
|
+
# (`root_aggregate:`, that class's own header) that surfaced one
|
|
395
|
+
# more live corpus bug of its own (`payment_cards.bluebook`'s own
|
|
396
|
+
# `Withdrawal.Dispute`, which never actually checked whether the
|
|
397
|
+
# card was retired) — all real, all kept.
|
|
384
398
|
#
|
|
385
|
-
#
|
|
386
|
-
#
|
|
387
|
-
#
|
|
388
|
-
#
|
|
389
|
-
#
|
|
390
|
-
#
|
|
391
|
-
#
|
|
399
|
+
# DELETING THIS METHOD ON THAT BASIS TURNED OUT TO BE WRONG,
|
|
400
|
+
# caught by running the full suite rather than trusting the
|
|
401
|
+
# audit's own scope: the 5 example domains are nowhere near the
|
|
402
|
+
# WHOLE inventory of `creates?`-true commands this fallback
|
|
403
|
+
# actually carries. Dozens of separate, purpose-built spec
|
|
404
|
+
# fixtures across the suite (`spec/fixtures/*.bluebook`, and
|
|
405
|
+
# inline `Hecks.bluebook` blocks declared directly inside
|
|
406
|
+
# individual spec files — governance, mutation ops, ports,
|
|
407
|
+
# routing, tenant isolation, sagas, and more) declare their OWN
|
|
408
|
+
# small `creates?`-true commands the same incomplete way, and
|
|
409
|
+
# rely on this exact fallback to create anything at all. Deleting
|
|
410
|
+
# it produced 218 failures across specs with nothing to do with
|
|
411
|
+
# Wave 8's own subject — confirmed, not guessed, by actually
|
|
412
|
+
# running `bundle exec rspec` after the deletion, not merely by
|
|
413
|
+
# extrapolating from the one audited inventory. `spec/fixtures/
|
|
414
|
+
# till.bluebook`'s own `Till.OpenTill` (missing `sets :number`,
|
|
415
|
+
# the identical bug shape as the 4 fixed above) was fixed
|
|
416
|
+
# alongside the four real corpus ones as one instance of this — but
|
|
417
|
+
# it was one of many, not the last one, and finding the rest is
|
|
418
|
+
# real, separate, much larger follow-up work this pass does not
|
|
419
|
+
# attempt: a suite-wide audit of every declared bluebook fixture,
|
|
420
|
+
# not just the 5 example domains the plan's own text named.
|
|
421
|
+
#
|
|
422
|
+
# Transitional compatibility for live source that has not yet
|
|
423
|
+
# acquired explicit effects, same as it always was — deliberately
|
|
424
|
+
# isolated from the normal routing and planning path so
|
|
425
|
+
# `reference_to` no longer chooses how a migrated command hydrates
|
|
426
|
+
# or persists. A real, future Wave 8 removes this once THAT wider
|
|
427
|
+
# inventory is empty, not before.
|
|
428
|
+
#
|
|
429
|
+
# `ctx.plan.complete_state?` already claimed every command whose
|
|
430
|
+
# plan is fully resolved in the two branches above `step_hydrate`
|
|
431
|
+
# tries first, so reaching this check already means the plan is
|
|
432
|
+
# incomplete — an un-migrated command still routes here on
|
|
433
|
+
# `creates?` alone, the same as the old `hydrate` did, regardless
|
|
434
|
+
# of whether it happens to have any mutations (`write_set`).
|
|
435
|
+
# Requiring an EMPTY write_set here refused every un-migrated
|
|
436
|
+
# creating command that sets even one field.
|
|
392
437
|
def legacy_implicit_creation?(ctx)
|
|
393
438
|
ctx.route.nil? && ctx.command.creates?
|
|
394
439
|
end
|
|
@@ -421,9 +466,11 @@ module Hecks
|
|
|
421
466
|
aggregate: aggregate.hecks_name,
|
|
422
467
|
identity: identity_reading(aggregate)))
|
|
423
468
|
|
|
424
|
-
# A SECOND CREATION IS NOT A FRESH ONE —
|
|
425
|
-
#
|
|
426
|
-
#
|
|
469
|
+
# A SECOND CREATION IS NOT A FRESH ONE — `creates?` on an identity
|
|
470
|
+
# a record already exists under refuses (`AlreadyExists`) rather
|
|
471
|
+
# than silently overwriting it, the same refusal `hydrate_prior_
|
|
472
|
+
# or_initial`'s own body gives for its own complete-but-state-
|
|
473
|
+
# dependent case, below. ONLY when `strategy` will NOT be ATOMIC_PUT:
|
|
427
474
|
# an atomic-put-capable adapter enforces this itself, atomically,
|
|
428
475
|
# via `insert_only:` in step_save (no read here, no race with the
|
|
429
476
|
# write) — but `strategy_for` already fell back to a plain `save`
|
|
@@ -461,8 +508,8 @@ module Hecks
|
|
|
461
508
|
identity: identity_reading(aggregate)))
|
|
462
509
|
found = repository.find(id)
|
|
463
510
|
|
|
464
|
-
# A SECOND CREATION IS NOT A FRESH ONE — see
|
|
465
|
-
#
|
|
511
|
+
# A SECOND CREATION IS NOT A FRESH ONE — see hydrate_complete_
|
|
512
|
+
# state's own comment; the same refusal, on the same terms, for
|
|
466
513
|
# a complete-but-state-dependent command (one with a `given`
|
|
467
514
|
# reading its own prior state, which is what routes here instead
|
|
468
515
|
# of hydrate_complete_state). Gated on `command.creates?`: a
|
|
@@ -116,7 +116,13 @@ module Hecks
|
|
|
116
116
|
# parent aggregate reaching ITS OWN customer) resolves the same
|
|
117
117
|
# way `account.customer.status` does for a command-level reference.
|
|
118
118
|
# nil for an aggregate command — CommandInterpreter never passes it.
|
|
119
|
-
|
|
119
|
+
# `correction:` — the `{as_name => payload}` bindings
|
|
120
|
+
# `enforce_correction_target` (above) already located, merged in
|
|
121
|
+
# LAST so an `as:` name wins the same way `old:` always wins in
|
|
122
|
+
# `enforce_ensures`, below — it is a fresh local binding a
|
|
123
|
+
# `corrects` command introduces, not a real argument/state field
|
|
124
|
+
# a caller could collide with by accident.
|
|
125
|
+
def enforce_givens(subject, command, args, domain:, declaring: nil, parent: nil, correction: {})
|
|
120
126
|
state = GuardState.new(subject)
|
|
121
127
|
# A RULE MAY ONLY READ WITHIN ITS OWN AGGREGATE BOUNDARY (S12,
|
|
122
128
|
# ADR 0025) — `subject`'s own STORED references are no longer
|
|
@@ -135,6 +141,7 @@ module Hecks
|
|
|
135
141
|
# forbids.
|
|
136
142
|
attrs = args.merge(dereference(domain, command, args))
|
|
137
143
|
attrs = attrs.merge(parent: parent.state) if parent
|
|
144
|
+
attrs = attrs.merge(correction) unless correction.empty?
|
|
138
145
|
command.givens.each do |given|
|
|
139
146
|
next if Bluebook::Expression::Evaluator.call(given.canonical, state, attrs)
|
|
140
147
|
|
|
@@ -154,20 +161,43 @@ module Hecks
|
|
|
154
161
|
# event at all — is `AggregateBuilder#seal_correction_targets`;
|
|
155
162
|
# this is the dispatch-time half — has THIS record actually done
|
|
156
163
|
# so yet.
|
|
164
|
+
#
|
|
165
|
+
# ALSO LOCATES the matched event now, not just its existence, and
|
|
166
|
+
# returns a `{as_name => payload}` bindings hash — one entry per
|
|
167
|
+
# `:corrects` mutation that named an `as:` — so `given`/`ensures`
|
|
168
|
+
# on a corrects-bearing command can reference the located OLD
|
|
169
|
+
# event by that name, the same shape `enforce_ensures`'s own
|
|
170
|
+
# `old:` binding already has (CommandBuilder#corrects_impl's own
|
|
171
|
+
# comment: `as:` was stored, from the start, specifically to be
|
|
172
|
+
# wired into the evaluator once a real runtime consumer existed —
|
|
173
|
+
# this is that consumer). `.reverse.find` — the MOST RECENT
|
|
174
|
+
# matching event, if this record has somehow emitted the same
|
|
175
|
+
# correction target more than once; the prior existence-only
|
|
176
|
+
# check never had to make this choice, so it's a genuinely new
|
|
177
|
+
# one, made deliberately: `as:` reads as "the instance being
|
|
178
|
+
# corrected," which is naturally the latest fact on record, not
|
|
179
|
+
# an arbitrary one.
|
|
157
180
|
def enforce_correction_target(instance, aggregate, command, domain:)
|
|
181
|
+
bindings = {}
|
|
158
182
|
command.mutations.each do |mutation|
|
|
159
183
|
next unless mutation.op == :corrects
|
|
160
184
|
|
|
161
185
|
event_key = "#{domain}::#{aggregate.hecks_name}"
|
|
162
186
|
event_name = mutation.target.to_s
|
|
163
|
-
|
|
187
|
+
corrected = @registry.event_log.reverse.find do |event|
|
|
164
188
|
event.name == event_name && event.aggregate == event_key && event.id == instance.id
|
|
165
189
|
end
|
|
166
190
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
191
|
+
unless corrected
|
|
192
|
+
raise NothingToCorrect,
|
|
193
|
+
"#{command.hecks_name} refused — corrects #{event_name}, but " \
|
|
194
|
+
"#{event_key} ##{instance.id} has never emitted it"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
as = mutation.source[:as]
|
|
198
|
+
bindings[as.to_sym] = corrected.payload if as && !as.to_s.empty?
|
|
170
199
|
end
|
|
200
|
+
bindings
|
|
171
201
|
end
|
|
172
202
|
|
|
173
203
|
# LIFECYCLE STATE AS A COMMAND GUARD (S10, ADR 0025) — `command
|
|
@@ -215,16 +245,21 @@ module Hecks
|
|
|
215
245
|
# Not new to ensures — `given` lives under the same rule — but an
|
|
216
246
|
# ensures is more likely to collide, since it typically re-reads a
|
|
217
247
|
# field the command just took in to mutate it.
|
|
218
|
-
def enforce_ensures(subject, command, args, old:, domain:, parent: nil)
|
|
248
|
+
def enforce_ensures(subject, command, args, old:, domain:, parent: nil, correction: {})
|
|
219
249
|
state = GuardState.new(subject)
|
|
220
250
|
# S12, ADR 0025 — same boundary reasoning as enforce_givens
|
|
221
251
|
# above: `subject`'s own stored references are no longer
|
|
222
252
|
# dereferenced here; a `projects`-maintained field is already
|
|
223
253
|
# part of `state`. `command`/`args` still dereferences — a
|
|
224
254
|
# fresh reference-typed ARGUMENT stays in bounds.
|
|
225
|
-
# `old` still wins over everything, unchanged.
|
|
255
|
+
# `old` still wins over everything, unchanged. `correction`
|
|
256
|
+
# (an `as:`-bound corrected event, if this command declares
|
|
257
|
+
# one) wins right alongside it — a settled-record ensures can
|
|
258
|
+
# reference the correction target exactly as freely as a
|
|
259
|
+
# pre-mutation given already can.
|
|
226
260
|
attrs = args.merge(dereference(domain, command, args))
|
|
227
261
|
attrs = attrs.merge(parent: parent.state) if parent
|
|
262
|
+
attrs = attrs.merge(correction) unless correction.empty?
|
|
228
263
|
attrs = attrs.merge(old: old)
|
|
229
264
|
command.ensures.each do |rule|
|
|
230
265
|
next if Bluebook::Expression::Evaluator.call(rule.canonical, state, attrs)
|
|
@@ -68,9 +68,39 @@ module Hecks
|
|
|
68
68
|
# made. The real mistake, an absent argument, was never the one refused,
|
|
69
69
|
# which is what fuzz surfaced.
|
|
70
70
|
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
71
|
+
# STALE (as of the equivalence-gap plan's own audit): this used to
|
|
72
|
+
# say "the language cannot yet say which arguments are optional" —
|
|
73
|
+
# it already can, and always could once `attribute ..., optional:
|
|
74
|
+
# true` existed (`CommandBuilder#attribute_impl`,
|
|
75
|
+
# `attribute_collector.rb`): `sets` already sources correctly from
|
|
76
|
+
# an optional attribute, resolving absent to nil exactly as this
|
|
77
|
+
# method does, and REFUSING it here would be wrong, not merely
|
|
78
|
+
# undone work — `TillRoom::Till.TakeIn`'s own `note` (spec/
|
|
79
|
+
# fixtures/till.bluebook) and Banking's `CardPayment.Authorize`'s
|
|
80
|
+
# `tags` (payment_cards.bluebook) are real, live commands whose
|
|
81
|
+
# `sets` mutation is deliberately sourced from an optional
|
|
82
|
+
# attribute the caller may omit — `spec/runtime/command_rules_spec
|
|
83
|
+
# .rb`'s own "says an absent OPTIONAL argument is nil, not the
|
|
84
|
+
# name of the argument" pins exactly this as correct, not pending.
|
|
85
|
+
# The meta-domain's own self-hosted commands (Command.Declare's
|
|
86
|
+
# `role`/`goal`/`provenance`/`from`/`position`, and ~35 more sites
|
|
87
|
+
# across the language) all lean on the identical pattern — nil is
|
|
88
|
+
# the RIGHT answer for a `sets` sourced from a declared-optional
|
|
89
|
+
# attribute the caller left out, every time.
|
|
90
|
+
#
|
|
91
|
+
# The one thing that WAS still a real, narrow gap — a `sets`
|
|
92
|
+
# source Symbol naming NOTHING the command declares at all (a
|
|
93
|
+
# typo, not an optional argument) — silently resolved to nil
|
|
94
|
+
# forever the same way, indistinguishable at either build or run
|
|
95
|
+
# time from a legitimate optional absence. Closed at BUILD time
|
|
96
|
+
# instead of here: `CommandBuilder#refuse_unknown_argument_sources!`
|
|
97
|
+
# refuses it the moment the `.bluebook` file loads, mirroring
|
|
98
|
+
# `AggregateBuilder#seal_query_argument`'s identical check for a
|
|
99
|
+
# query's own where-clause argument. This function stays exactly
|
|
100
|
+
# what it always was — a pure, unconditional lookup — because by
|
|
101
|
+
# the time ANY mutation reaches it, the source has already been
|
|
102
|
+
# proven to name either a real, possibly-optional argument, or a
|
|
103
|
+
# StateRef/literal; there is nothing left here to refuse.
|
|
74
104
|
def resolve_source(source, args)
|
|
75
105
|
return args[source] if source.is_a?(Symbol)
|
|
76
106
|
|
|
@@ -62,13 +62,29 @@ module Hecks
|
|
|
62
62
|
class Analyzer
|
|
63
63
|
STATEFUL_MUTATIONS = %i[append increment decrement multiply clamp remove].freeze
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
# `root_aggregate:` — Wave 8's own audit surfaced a real bug here,
|
|
66
|
+
# not merely a missing feature: for an ENTITY-owned command,
|
|
67
|
+
# `EntityInterpreter` calls this with `aggregate:` set to the
|
|
68
|
+
# ENTITY itself (`element_interpreter.rb`'s own `Analyzer.call
|
|
69
|
+
# (aggregate: entity, command:)`), so `owner_fields` was always
|
|
70
|
+
# the entity's own attribute set. A `given`/`ensures` reading
|
|
71
|
+
# `parent.X` legitimately means the ROOT aggregate's own field —
|
|
72
|
+
# a genuinely different owner — but `classify_path`'s `:parent`
|
|
73
|
+
# branch checked that read against `owner_fields` (the entity's),
|
|
74
|
+
# which can never contain a root-level field, so every entity
|
|
75
|
+
# command with a real, legitimate `parent.*` read was refused as
|
|
76
|
+
# unresolved regardless of correctness. Defaults to `aggregate`
|
|
77
|
+
# (a no-op) for the plain-aggregate case — `CommandInterpreter`'s
|
|
78
|
+
# own call site never needed to change.
|
|
79
|
+
def self.call(aggregate:, command:, root_aggregate: aggregate) = new(aggregate, command, root_aggregate).call
|
|
80
|
+
|
|
81
|
+
def initialize(aggregate, command, root_aggregate = aggregate)
|
|
68
82
|
@aggregate = aggregate
|
|
69
83
|
@command = command
|
|
70
84
|
@owner_fields = aggregate.attributes.to_set(&:name)
|
|
71
85
|
@owner_fields << aggregate.lifecycle.field.to_sym if aggregate.lifecycle
|
|
86
|
+
@root_owner_fields = root_aggregate.attributes.to_set(&:name)
|
|
87
|
+
@root_owner_fields << root_aggregate.lifecycle.field.to_sym if root_aggregate.lifecycle
|
|
72
88
|
# `projects` FIELDS (S12, ADR 0025) ARE OWNER STATE TOO — a
|
|
73
89
|
# `given`/`ensures` reading one (e.g. `customer_status ==
|
|
74
90
|
# "active"`) is reading this record's own stored field, same
|
|
@@ -80,9 +96,17 @@ module Hecks
|
|
|
80
96
|
# a partial mutation, which is exactly right — a projected
|
|
81
97
|
# field's freshness comes from the interpreter reseeding it on
|
|
82
98
|
# save, not from anything a caller-supplied write set carries.
|
|
99
|
+
# Applies to BOTH `owner_fields` and `root_owner_fields` — an
|
|
100
|
+
# entity's own `parent.*` read can name the root aggregate's
|
|
101
|
+
# projected field just as easily as one of its real attributes
|
|
102
|
+
# (`Banking::Withdrawal.Dispute`'s own `parent.account_customer_
|
|
103
|
+
# status`, ATMCard's projected field, is a real, live example).
|
|
83
104
|
if aggregate.respond_to?(:projected_fields)
|
|
84
105
|
aggregate.projected_fields.each { |field| @owner_fields << field.name }
|
|
85
106
|
end
|
|
107
|
+
if root_aggregate.respond_to?(:projected_fields)
|
|
108
|
+
root_aggregate.projected_fields.each { |field| @root_owner_fields << field.name }
|
|
109
|
+
end
|
|
86
110
|
@payload_fields = command.attributes.to_set(&:name)
|
|
87
111
|
@state_reads = Set.new
|
|
88
112
|
@payload_reads = Set.new
|
|
@@ -115,7 +139,7 @@ module Hecks
|
|
|
115
139
|
|
|
116
140
|
private
|
|
117
141
|
|
|
118
|
-
attr_reader :aggregate, :command, :owner_fields, :payload_fields,
|
|
142
|
+
attr_reader :aggregate, :command, :owner_fields, :root_owner_fields, :payload_fields,
|
|
119
143
|
:state_reads, :payload_reads, :writes, :known_writes, :unresolved
|
|
120
144
|
|
|
121
145
|
# A fresh Instance supplies these values without reading a stored
|
|
@@ -204,13 +228,36 @@ module Hecks
|
|
|
204
228
|
end
|
|
205
229
|
end
|
|
206
230
|
|
|
231
|
+
# KNOWN, HARMLESS GAP: `corrects ..., as: :name`'s bound name
|
|
232
|
+
# (admissibility.rb's `enforce_correction_target`/`enforce_givens`/
|
|
233
|
+
# `enforce_ensures`) isn't special-cased here the way `:old`/
|
|
234
|
+
# `:parent` are — a given/ensures referencing it falls through to
|
|
235
|
+
# `unresolved` below (its own field lookup finds no owner/payload
|
|
236
|
+
# match), same net effect as any other not-yet-optimized command:
|
|
237
|
+
# `complete_state?` comes back false, so dispatch takes the safe
|
|
238
|
+
# `hydrate_existing` path instead of the `ATOMIC_PUT` fast path.
|
|
239
|
+
# Not a correctness bug — `as:`'s runtime binding (a plain `attrs`
|
|
240
|
+
# merge, exactly like `old:`'s) resolves and evaluates correctly
|
|
241
|
+
# regardless of what this STATIC analysis concludes — just a real,
|
|
242
|
+
# deliberately-left optimization gap: closing it would mean
|
|
243
|
+
# threading "which names this command declares as correction
|
|
244
|
+
# bindings" into the Analyzer, which doesn't have that per-command
|
|
245
|
+
# context today. Worth doing alongside `:old`/`:parent`'s own
|
|
246
|
+
# handling someday, not attempted here.
|
|
207
247
|
def classify_path(path, phase)
|
|
208
248
|
head, nested = path.split(".", 2)
|
|
209
249
|
name = head.to_sym
|
|
210
250
|
|
|
211
251
|
if name == :parent
|
|
212
252
|
parent_field = nested.to_s.split(".", 2).first
|
|
213
|
-
|
|
253
|
+
# `root_owner_fields` — NOT `owner_fields`. For an entity-owned
|
|
254
|
+
# command `owner_fields` is the ENTITY's own attribute set;
|
|
255
|
+
# `parent.X` always means the ROOT aggregate's own field, a
|
|
256
|
+
# genuinely different owner (`root_aggregate:`'s own header,
|
|
257
|
+
# above, has the full bug this fixes). Identical for a plain
|
|
258
|
+
# aggregate command, where root_aggregate defaults to aggregate
|
|
259
|
+
# itself and the two sets are the same set.
|
|
260
|
+
if parent_field.empty? || !root_owner_fields.include?(parent_field.to_sym)
|
|
214
261
|
unresolved << "#{path} does not name parent aggregate state"
|
|
215
262
|
else
|
|
216
263
|
state_reads << parent_field.to_sym
|
|
@@ -98,7 +98,14 @@ module Hecks
|
|
|
98
98
|
ctx.chain = chain
|
|
99
99
|
ctx.route = route
|
|
100
100
|
ctx.dry_run = dry_run
|
|
101
|
-
|
|
101
|
+
# `root_aggregate:` — `entity` is the immediate owner (what
|
|
102
|
+
# `owner_fields` inside the Analyzer means), but a `parent.X`
|
|
103
|
+
# read inside this command's own given/ensures means the ROOT
|
|
104
|
+
# aggregate's own field, not the entity's — `aggregate` here IS
|
|
105
|
+
# that root (this method's own first parameter, never the
|
|
106
|
+
# entity). See DependencyPlanning::Analyzer.call's own header
|
|
107
|
+
# for the bug this closes.
|
|
108
|
+
ctx.plan = DependencyPlanning::Analyzer.call(aggregate: entity, command: command, root_aggregate: aggregate)
|
|
102
109
|
# RESOLVED HERE, ONCE — see CommandInterpreter#call's own comment;
|
|
103
110
|
# `step_hydrate_parent` reads `ctx.repository` without re-fetching.
|
|
104
111
|
ctx.repository = @registry.repository(domain, aggregate)
|
|
@@ -73,10 +73,19 @@ module Hecks
|
|
|
73
73
|
[fetch(bluebook, domain, head[:aggregate], reference_id)]
|
|
74
74
|
elsif rootless
|
|
75
75
|
# No root to FK-match against — a rootless model reads
|
|
76
|
-
# each of its own heads WHOLE, independently.
|
|
77
|
-
# heads on one rootless model
|
|
78
|
-
# against each other
|
|
79
|
-
#
|
|
76
|
+
# each of its own heads WHOLE, independently. Multiple
|
|
77
|
+
# heads on one rootless model are NEVER cross-joined
|
|
78
|
+
# against each other, and there is no DSL to declare
|
|
79
|
+
# one if you wanted to — `ReadModelBuilder#include_impl`
|
|
80
|
+
# takes only `type`/`as:` (checked directly, not
|
|
81
|
+
# assumed), and `group_by` groups this bulk read's own
|
|
82
|
+
# output, it names no predicate between two heads.
|
|
83
|
+
# Building a cross-join here would mean CHOOSING a join
|
|
84
|
+
# semantics (equality on which fields?) nobody has
|
|
85
|
+
# declared — a real, deliberate scope limit pending a
|
|
86
|
+
# future `include ..., joins: ...`-shaped grammar
|
|
87
|
+
# addition (with its own Rust mirror), not a gap this
|
|
88
|
+
# interpreter can quietly grow into on its own.
|
|
80
89
|
records(bluebook, domain, head[:aggregate])
|
|
81
90
|
else
|
|
82
91
|
matching(records(bluebook, domain, head[:aggregate])) do |record|
|