Class: Hecks::Bluebook::DSL::BluebookBuilder
- Inherits:
-
Object
- Object
- Hecks::Bluebook::DSL::BluebookBuilder
- Includes:
- WordGate
- Defined in:
- lib/hecks/bluebook/dsl/bluebook_builder.rb
Constant Summary collapse
- GRAMMAR_CONTEXT =
"Bluebook"
Constants included from WordGate
Instance Attribute Summary collapse
-
#classification ⇒ Object
readonly
Returns the value of attribute classification.
Class Method Summary collapse
-
.build(name, version: nil, &block) ⇒ Object
A CHAPTER MAY BE DECLARED IN SEVERAL FILES, meant to merge into ONE domain —
lib/hecks/language/bluebook/*.bluebookall open `Hecks.bluebook "Bluebook" do ... -
.check_with_spec!(command_ref, event_name, with_spec, lookup, label, aggregates, correlation_heads, pm: nil) ⇒ Object
pm:is present only for a process manager's own dispatch — a saga leg's source symbol resolves against the CURRENT triggering event first, same as a policy, but falls all the way back to the saga's own MEMORY when the current event does not carry it (SagaInterpreter#dispatch_args, its own lastelse) — and memory starts as the OPENING event's payload (SagaInterpreter#instance = { ..., memory: event.payload }, never updated after), never the leg's own. -
.command_declares?(command, field, aggregates, correlation_heads) ⇒ Boolean
A command's OWN
reference_to(bare, noas:) never lands inattributes—CommandBuilder#reference_to's self-reference branch setscommand.referencesinstead (S2), and mints no new field at all. - .command_lookup(aggregates) ⇒ Object
-
.correlation_heads(process_managers) ⇒ Object
THE FOURTH addressing key
ArgumentGate#refuse_unknown_argumentsaccepts, alongside:id/identity_heads/reference_key— every saga in THIS domain's owncorrelates_byhead, carried through every dispatch as pure passthrough (Settlement's own comment: "reference:carries the correlation forward... this is pure passthrough, not an addressing key"). - .correlation_key_violation(pm, aggregates) ⇒ Object
-
.each_command(aggregates) ⇒ Object
Every command this chapter declares, an aggregate's own AND every entity nested inside one, paired with a name for what declares it — shared by
validate_event_shapes!andvalidate_with_projections!'s own command lookup, the same reachHecksagonBuilder#commands_inneeds one level up (S8). - .emitting_commands(events, aggregates) ⇒ Object
-
.event_emitters(aggregates) ⇒ Object
NOT MEMOISED — this used to be
@event_emitters ||=on the builder instance, which is safe for a one-file chapter but wrong for one split across several: the FIRST file's build() call would compute and cache it from whatever@aggregatesheld at that moment, and every later file's own validation would keep reading that same stale snapshot, silently missing any command a later file adds. -
.event_identity_heads_for(event_name, aggregates) ⇒ Object
The identity heads of the aggregate that emits
event_name— an entity's event is stamped with its OWNING aggregate's identity (Event#idis the parent's), so an owner spelled "Game.Knight" answers Game's heads. -
.event_shape(command, owner) ⇒ Object
STRUCTURAL, NOT NOMINAL.
- .event_shape_for(event_name, aggregates) ⇒ Object
-
.find_reference_cycle(edges) ⇒ Object
Plain DFS with a visiting/done coloring, over the reference graph THIS chapter's own aggregates declare.
-
.infer_hop_query_arguments!(bluebook) ⇒ Object
The chapter-wide half of AggregateBuilder's local query-argument inference.
- .list_or_scalar_violation(owner, attribute, segments) ⇒ Object
-
.owner_aggregate(owner, aggregates) ⇒ Object
owner(fromeach_command) is a plain STRING — the aggregate'shecks_namealone, or"Aggregate.Entity"for an entity's own command. - .projectable_scalar?(target, attribute) ⇒ Boolean
- .reacted_events(pm) ⇒ Object
- .reference_cycle_from(node, edges, state, path) ⇒ Object
- .refuse_entity_query_hops!(aggregate, entity) ⇒ Object
- .unwrap_shape(owner, type_name, seen = []) ⇒ Object
-
.validate_assembled!(bluebook) ⇒ Object
EVERY WHOLE-CHAPTER CHECK, IN ONE PLACE — the battery
#buildused to run inline, now a pure function of an assembledBluebook::ChaptersoMetaValidator.judge_deferred!can run it too, once, on a chapter whose files have ALL loaded (see#build's own comment for why that split exists at all). -
.validate_correlation_keys!(process_managers, aggregates) ⇒ Object
correlates_byNAMES A SCALAR, NOW CHECKED RATHER THAN TRUSTED. -
.validate_event_shapes!(aggregates) ⇒ Object
EVENTS ARE FIRST-CLASS BY CONVENTION, NOT BY DECLARATION (ADR 0025, "events and reactions" — "a domain event is a value object with its own attributes, not a label").
- .validate_hop_clause!(aggregate, query, clause) ⇒ Object
-
.validate_hop_comparator!(aggregate, query, clause, target, attribute, nested) ⇒ Object
A WHERE hop with an ordered comparator is legitimate ("client whose balance > 500") — AggregateBuilder#seal_ordered_comparator already deferred this exact check for the same reason every other hop check is deferred, and this is where it gets asked, against the hop's TARGET instead of the querying aggregate.
-
.validate_hop_tail!(aggregate, query, clause, target, tail) ⇒ Object
The same three-way answer seal_query_field gives for its OWN aggregate's fields — landing on a real scalar (fine), landing on a value object (refused by name), or naming nothing at all (refused by name) — asked instead of the hop's TARGET aggregate, since that is whose shape the tail actually has to answer for.
-
.validate_no_bidirectional_references!(aggregates) ⇒ Object
A REFERENCE RING IS NOT A MODELLING CHOICE, IT IS A MISSING ONE — a DDD aggregate is a consistency boundary precisely because something outside it can only ever point IN, by id, never the other way.
- .validate_projected_field!(aggregate, field) ⇒ Object
-
.validate_projected_fields!(bluebook) ⇒ Object
THE TARGET HALF of
projectsvalidation (S12, ADR 0025) —AggregateBuilder#seal_projected_fieldsalready checked the LOCAL half at declare time (the reference names a realreference_toon THIS aggregate); this checks the reference actually resolves to a real aggregate in this chapter, and that aggregate really declaresremote_fieldas a scalar. -
.validate_query_hops!(bluebook) ⇒ Object
THE OTHER HALF OF A HOP — AggregateBuilder#seal_query_field recognised the HEAD of a dotted where-field that names one of its own references and deferred it here, unable to check further: it cannot yet resolve what the reference points AT.
-
.validate_reference_value_objects!(aggregates) ⇒ Object
AN ENTITY COMMAND MAY NOT NAME ITSELF AS ITS ROOT.
-
.validate_with_projections!(policies, process_managers, aggregates) ⇒ Object
THE "EXPENSIVE HALF" the ADR names: "with: { account: :account } projecting into a reaction that has no declared contract ...
-
.walk_scalar(owner, type_name, segments) ⇒ Object
Walks the remaining dotted segments through nested value objects.
Instance Method Summary collapse
-
#aggregate_impl(name, &block) ⇒ Object
@chapter_named_givensis threaded into every aggregate this chapter builds — seeAggregateBuilder#given's own comment for the sharing this enables; NOT a new top-level DSL word itself (an aggregate's own EXISTINGgivenalready both declares locally and write-throughs here as a side effect, the identical shapeEntityBuilder#given's own write-through to its owner aggregate's pool already takes — no new spelling for "declare a precondition," one level wider, same word). -
#attaches_to_impl(*contexts) ⇒ Object
A SUB-LANGUAGE NAMES WHERE IT LANDS.
- #build ⇒ Object
- #core ⇒ Object
-
#formerly_known_as(value) ⇒ Object
A domain's own identity can change — this names what it used to be, so the storage layer can recognize its own history under the old name instead of minting a brand-new lineage from nothing.
- #generic ⇒ Object
-
#initialize(name, version: nil) ⇒ BluebookBuilder
constructor
A new instance of BluebookBuilder.
- #policy(name, &block) ⇒ Object
- #process_manager(name, &block) ⇒ Object
-
#read_model(name, &block) ⇒ Object
read_modelis the word (ADR 0025 revertsreport— the IR construct, the registry API, and the docs filename all saidread_modelthe whole time; no era was ever minted underreport, so this is history and source agreeing again). - #report(name, &block) ⇒ Object
-
#resolve_pending_chapter_entity_givens! ⇒ Object
THE ENTITY-SCOPED ANALOGUE, one level down — see
#resolve_pending_chapter_givens!'s own comment; identical shape, resolved against@chapter_entity_named_givensinstead. -
#resolve_pending_chapter_givens! ⇒ Object
THE OTHER HALF OF A CHAPTER-WIDE
givenREFERENCE —AggregateBuilder#pending_chapter_givenrecognised an unresolved bare reference and deferred it here, unable to check further: a later file in this SAME chapter might still declare the real thing. - #supporting ⇒ Object
- #vision(value) ⇒ Object
Constructor Details
#initialize(name, version: nil) ⇒ BluebookBuilder
Returns a new instance of BluebookBuilder.
12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 12 def initialize(name, version: nil) @name = name @version = version @aggregates = [] @read_models = [] @policies = [] @process_managers = [] # THE ROOT of the CHAPTER-WIDE given pool — one level wider # than `AggregateBuilder`'s own `@entity_named_givens` (S10 # extended across an aggregate's whole entity tree, earlier # this arc). See `#given`'s own comment for what this closes. @chapter_named_givens = {} # EVERY BARE CHAPTER-GIVEN REFERENCE THIS CHAPTER'S OWN FILES # LEFT UNRESOLVED SO FAR — threaded into every aggregate the # same way `@chapter_named_givens` is. See # `AggregateBuilder#pending_chapter_given`'s own comment for # what queues here and `#resolve_pending_chapter_givens!`, # below, for where it drains. @chapter_pending_givens = [] # ONE LEVEL WIDER STILL — the CHAPTER-WIDE, ENTITY-SCOPED pool # (the piece analogue of `@chapter_named_givens`, above). See # `EntityBuilder#given_impl`'s own comment for what this # closes; `docs/implemented/resolution-rules/ # chapter-entity-given.md` for the full algorithm. @chapter_entity_named_givens = {} @chapter_entity_pending_givens = [] end |
Dynamic Method Handling
This class handles dynamic methods through the method_missing method in the class Hecks::Bluebook::DSL::WordGate
Instance Attribute Details
#classification ⇒ Object (readonly)
Returns the value of attribute classification.
10 11 12 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 10 def classification @classification end |
Class Method Details
.build(name, version: nil, &block) ⇒ Object
A CHAPTER MAY BE DECLARED IN SEVERAL FILES, meant to merge into ONE
domain — lib/hecks/language/bluebook/*.bluebook all open
Hecks.bluebook "Bluebook" do ... end. Each Hecks.bluebook call used to
mint a fresh builder, so a second file with the same chapter name
silently replaced the first's aggregates instead of adding to them.
The registry now holds the builder OPEN across calls : the first file
for a name creates it, every later file for the same name reuses the
same instance, so @aggregates/@read_models accumulate. #build is
safe to call once per file on the same builder — it constructs a fresh
Bluebook from whatever is currently held and re-Namespace.installs
over the previous one, so the LAST file's call leaves every aggregate
seen so far reachable, and each call's IR is a strict superset of the
one before. Registry#add_bluebook still simply stores by name — with
this in place, "last write wins" is the cumulative, correct write.
1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 1066 def self.build(name, version: nil, &block) registry = Hecks.current_registry builder = registry ? registry.bluebook_builder(name) { new(name, version: version) } : new(name, version: version) builder.__send__(:adopt_version, version) # A bare constant in a bluebook — `attribute :name, PizzaName` — is a NAME, # not a reference to something Ruby has heard of. `const_missing` hands # over a `ConstShim::ScopedConstant` (S0b, const_shim.rb's own comment), # and that is still the whole answer for a bare name: `Attribute` spells # it with `to_s`, so the `TypeName` wrapper this used to build existed # only long enough to be stringified. The concept still has a home — the # language declares `value_object "TypeName"` — it just needed no Ruby # class of its own. A Module rather than a Symbol is what also lets # `Account::Debit`/`admits: Account::LedgerDirection` answer their OWN # `::` — a plain Symbol cannot. resolver = ->(const) { ConstShim::ScopedConstant.for(const) } ConstShim.with(resolver) { builder.instance_eval(&block) } if block builder.build end |
.check_with_spec!(command_ref, event_name, with_spec, lookup, label, aggregates, correlation_heads, pm: nil) ⇒ Object
pm: is present only for a process manager's own dispatch — a
saga leg's source symbol resolves against the CURRENT triggering
event first, same as a policy, but falls all the way back to the
saga's own MEMORY when the current event does not carry it
(SagaInterpreter#dispatch_args, its own last else) — and
memory starts as the OPENING event's payload
(SagaInterpreter#instance = { ..., memory: event.payload },
never updated after), never the leg's own. Settlement's own
comment names exactly this: "the credit leg reads a destination
no event carried" — AccountDebited never declares :reference,
only TransferRequested (pm.starts_on) does, and that is
where the value is genuinely still coming from.
447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 447 def self.check_with_spec!(command_ref, event_name, with_spec, lookup, label, aggregates, correlation_heads, pm: nil) target = lookup[command_ref] source_shape = event_name && event_shape_for(event_name, aggregates) memory_shape = pm && event_shape_for(pm.starts_on, aggregates) correlation = pm && pm.correlates_by && pm.correlation_head # A POLICY'S SOURCE ALSO CARRIES THE EMITTER'S OWN IDENTITY — # `PolicyInterpreter#emitter_identity`, the runtime half of this. # An entity command's event never declares its aggregate's # identity (it arrives through `reference_to`, not an # `attribute`), so before this a policy on `KnightCaptured` could # not spell `with: { label: :label }` at all — "reads :label off # KnightCaptured, which does not declare it" — and chess's # AdvancePly grew optional, unread attributes just to survive a # wholesale forward. Policies only: a saga leg's own source is # `SagaInterpreter#dispatch_args`, which merges no such thing. identity_sources = pm.nil? && event_name ? event_identity_heads_for(event_name, aggregates) : [] with_spec.each do |field, source| raise Malformed, "#{label}'s with: names #{field.inspect}, which #{command_ref} does not declare" if target && !command_declares?(target, field, aggregates, correlation_heads) next unless source.is_a?(::Symbol) next if source == correlation next if identity_sources.include?(source) next unless source_shape || memory_shape found = [source_shape, memory_shape].compact.any? { |shape| shape.any? { |name, *| name == source } } next if found raise Malformed, "#{label}'s with: reads :#{source} off #{event_name.inspect}, which does not declare it" end end |
.command_declares?(command, field, aggregates, correlation_heads) ⇒ Boolean
A command's OWN reference_to (bare, no as:) never lands in
attributes — CommandBuilder#reference_to's self-reference
branch sets command.references instead (S2), and mints no new
field at all. What addresses it is not one name but the SAME
SET CommandInterpreter::ArgumentGate#refuse_unknown_arguments
already accepts at dispatch time — :id, the owning aggregate's
own identity_heads (real corpus proof — Account.Debit
dispatched everywhere as number: ..., Account's own
identified_by), AND Naming.reference_key(command.references)
(real corpus proof — FreezeAccountsOnSuspension's for_each
fan-out, whose own comment reads "account is the key the
fan-out merges for each row it answers"). Both are simultaneously
legal there, not context-dependent alternatives, so both are
legal here : this mirrors that gate rather than re-deriving a
narrower rule that would refuse one of two real, already-shipped
dispatch conventions.
495 496 497 498 499 500 501 502 503 504 505 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 495 def self.command_declares?(command, field, aggregates, correlation_heads) return true if command.attributes.any? { |a| a.name == field } return true if field == :id return true if correlation_heads.include?(field) return false unless command.references referenced = aggregates.find { |a| a.hecks_name == command.references } return false unless referenced referenced.identity_heads.include?(field) || Naming.reference_key(command.references) == field end |
.command_lookup(aggregates) ⇒ Object
629 630 631 632 633 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 629 def self.command_lookup(aggregates) each_command(aggregates).each_with_object({}) do |(owner, command), index| index["#{owner}.#{command.hecks_name}"] = command end end |
.correlation_heads(process_managers) ⇒ Object
THE FOURTH addressing key ArgumentGate#refuse_unknown_arguments
accepts, alongside :id/identity_heads/reference_key — every
saga in THIS domain's own correlates_by head, carried through
every dispatch as pure passthrough (Settlement's own comment:
"reference: carries the correlation forward... this is pure
passthrough, not an addressing key"). A command declaring none of
its attributes named this is not a gap; the correlation key rides
through commands that never read it, same as it does at runtime.
515 516 517 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 515 def self.correlation_heads(process_managers) process_managers.filter_map { |pm| pm.correlates_by && pm.correlation_head } end |
.correlation_key_violation(pm, aggregates) ⇒ Object
984 985 986 987 988 989 990 991 992 993 994 995 996 997 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 984 def self.correlation_key_violation(pm, aggregates) head, *rest = pm.correlates_by.to_s.split(".") events = reacted_events(pm) emitting_commands(events, aggregates).each do |owner, command| attribute = command.attributes.find { |a| a.name == head.to_sym } next unless attribute reason = list_or_scalar_violation(owner, attribute, rest) return reason if reason end nil end |
.each_command(aggregates) ⇒ Object
Every command this chapter declares, an aggregate's own AND
every entity nested inside one, paired with a name for what
declares it — shared by validate_event_shapes! and
validate_with_projections!'s own command lookup, the same
reach HecksagonBuilder#commands_in needs one level up (S8).
524 525 526 527 528 529 530 531 532 533 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 524 def self.each_command(aggregates) return enum_for(:each_command, aggregates) unless block_given? aggregates.each do |aggregate| aggregate.commands.each { |command| yield aggregate.hecks_name, command } aggregate.entities.each do |entity| entity.commands.each { |command| yield "#{aggregate.hecks_name}.#{entity.hecks_name}", command } end end end |
.emitting_commands(events, aggregates) ⇒ Object
1007 1008 1009 1010 1011 1012 1013 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 1007 def self.emitting_commands(events, aggregates) aggregates.flat_map do |aggregate| commands = aggregate.commands + aggregate.entities.flat_map(&:commands) commands.select { |command| (command.emits.map(&:to_s) & events).any? } .map { |command| [aggregate, command] } end end |
.event_emitters(aggregates) ⇒ Object
NOT MEMOISED — this used to be @event_emitters ||= on the
builder instance, which is safe for a one-file chapter but
wrong for one split across several: the FIRST file's build()
call would compute and cache it from whatever @aggregates
held at that moment, and every later file's own validation
would keep reading that same stale snapshot, silently missing
any command a later file adds. Recomputed fresh every call
instead — this walks the whole chapter once per #build, not
a hot path worth memoising at that cost.
544 545 546 547 548 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 544 def self.event_emitters(aggregates) each_command(aggregates).each_with_object(Hash.new { |h, k| h[k] = [] }) do |(owner, command), index| command.emits.each { |event_name| index[event_name] << [owner, command] } end end |
.event_identity_heads_for(event_name, aggregates) ⇒ Object
The identity heads of the aggregate that emits event_name — an
entity's event is stamped with its OWNING aggregate's identity
(Event#id is the parent's), so an owner spelled "Game.Knight"
answers Game's heads.
610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 610 def self.event_identity_heads_for(event_name, aggregates) pairs = event_emitters(aggregates).fetch(event_name.to_s, []) return [] if pairs.empty? owner_name, = pairs.first aggregate = owner_aggregate(owner_name, aggregates) return [] unless aggregate heads = aggregate.identity_heads.map(&:to_sym) # AN ENTITY'S EVENT ALSO CARRIES THE PIECE'S OWN IDENTITY — the # args a piece was addressed by are the args its event announces # (`Emission#emit`: `payload: args`), so `id`-shaped heads are # genuinely there at runtime even though no `attribute` line on # the entity command declares them. entity_names = owner_name.to_s.split(".").drop(1) entity = entity_names.reduce(aggregate) { |owner, name| owner&.entities&.find { |e| e.hecks_name == name } } heads + (entity ? entity.identity_heads.map(&:to_sym) : []) end |
.event_shape(command, owner) ⇒ Object
STRUCTURAL, NOT NOMINAL. Two commands on two different
aggregates that both emits "SameEvent" are free to type a
field through two DIFFERENT, locally-scoped wrapper value
objects (e.g. one aggregate's own value: SomeText vs
another's value: OtherText, exactly the per-aggregate "own
text VO" convention every aggregate in this grammar already
follows for everything from RuleText to FieldRef) without
actually disagreeing about the event's shape — comparing
a.type by NAME would flag that as a violation for no real
reason: an event is one fact, and two isomorphic wrapper types
tell an identical one. So a value-object type is unwrapped to
its OWN attribute shape (recursively — a wrapper could itself
wrap another) before comparing, and only a primitive type
(nothing left to unwrap) or two VOs that truly differ once
unwrapped still counts as a real mismatch. owner carries the
type's value_object lookup — a command's own attributes only
know their type's NAME, never the aggregate that declared it,
and two sibling aggregates in one chapter each keep a
same-named VO private to themselves, so the unwrap has to ask
the SAME aggregate the field's own command belongs to, never a
neighbor's.
571 572 573 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 571 def self.event_shape(command, owner) command.attributes.map { |a| [a.name, unwrap_shape(owner, a.type.to_s), a.list?, a.optional?] }.sort end |
.event_shape_for(event_name, aggregates) ⇒ Object
598 599 600 601 602 603 604 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 598 def self.event_shape_for(event_name, aggregates) pairs = event_emitters(aggregates).fetch(event_name.to_s, []) return nil if pairs.empty? owner_name, command = pairs.first event_shape(command, owner_aggregate(owner_name, aggregates)) end |
.find_reference_cycle(edges) ⇒ Object
Plain DFS with a visiting/done coloring, over the reference graph THIS chapter's own aggregates declare. Returns the ring itself (in the order it closes), or nil.
680 681 682 683 684 685 686 687 688 689 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 680 def self.find_reference_cycle(edges) state = {} edges.each_key do |start| cycle = reference_cycle_from(start, edges, state, []) return cycle if cycle end nil end |
.infer_hop_query_arguments!(bluebook) ⇒ Object
The chapter-wide half of AggregateBuilder's local query-argument inference. A hop cannot resolve while its aggregate is still being built; here every Reference has an owner and target, so a symbolic comparison can inherit the type of the scalar it compares without a duplicate query-local declaration.
754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 754 def self.infer_hop_query_arguments!(bluebook) bluebook.aggregates.each do |aggregate| aggregate.queries.each do |query| query.wheres.each do |clause| name = clause.value next unless name.is_a?(Symbol) next if query.attribute(name) next unless QuerySpecification::HopPath.hop_head?(clause.field, aggregate.attributes) plan = QuerySpecification::HopPath.plan(clause.field, aggregate.attributes) next if plan.refusal || plan.hops.empty? target = plan.hops.last.target head, *nested = plan.tail.to_s.split(".") leaf = if nested.empty? && target.lifecycle&.field.to_s == head Attribute.new(name: name, type: String) else root = target.attributes.find { |candidate| candidate.name.to_s == head } found = root && QuerySpecification::FieldPath.leaf_attribute(root, nested) do |type| target.value_object(type) end found && Attribute.new(name: name, type: found.type, list: found.list?) end next unless leaf query.attributes << leaf end end end end |
.list_or_scalar_violation(owner, attribute, segments) ⇒ Object
1015 1016 1017 1018 1019 1020 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 1015 def self.list_or_scalar_violation(owner, attribute, segments) return "#{attribute.name} is a list — a correlation key must name one instance's own field, " \ "not a whole collection" if attribute.list? walk_scalar(owner, attribute.type.to_s, segments) end |
.owner_aggregate(owner, aggregates) ⇒ Object
owner (from each_command) is a plain STRING — the aggregate's
hecks_name alone, or "Aggregate.Entity" for an entity's own
command. Either way the VALUE OBJECTS a command's fields can be
typed with are the AGGREGATE's own (Entity carries no
value_object lookup of its own — the whole rest of this file
already resolves hop/type lookups only at the aggregate level,
e.g. validate_hop_tail!'s target.value_object(type)), so only
the first segment ever matters here.
594 595 596 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 594 def self.owner_aggregate(owner, aggregates) aggregates.find { |a| a.hecks_name == owner.to_s.split(".").first } end |
.projectable_scalar?(target, attribute) ⇒ Boolean
948 949 950 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 948 def self.projectable_scalar?(target, attribute) !attribute.list? && !attribute.reference? && target.value_object(attribute.type).nil? end |
.reacted_events(pm) ⇒ Object
999 1000 1001 1002 1003 1004 1005 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 999 def self.reacted_events(pm) ([pm.starts_on, pm.ends_on] + pm.handlers.map(&:event_type)) .compact .reject { |event| event == ProcessManager::REFUSED } .map { |event| event.to_s.split("::").last } .uniq end |
.reference_cycle_from(node, edges, state, path) ⇒ Object
691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 691 def self.reference_cycle_from(node, edges, state, path) return nil if state[node] == :done return path[path.index(node)..] if state[node] == :visiting state[node] = :visiting path.push(node) edges[node].each do |target| next unless edges.key?(target) # a name this chapter never declares is dangling, not an edge found = reference_cycle_from(target, edges, state, path) return found if found end path.pop state[node] = :done nil end |
.refuse_entity_query_hops!(aggregate, entity) ⇒ Object
785 786 787 788 789 790 791 792 793 794 795 796 797 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 785 def self.refuse_entity_query_hops!(aggregate, entity) entity.queries.each do |query| query.wheres.each do |clause| next unless QuerySpecification::HopPath.hop_head?(clause.field, entity.attributes) raise Malformed, "#{aggregate.hecks_name}::#{entity.hecks_name}.#{query.hecks_name} asks about " \ "#{clause.field}, which hops through #{entity.hecks_name}'s own reference — " \ "an entity query does not follow a hop the way an aggregate's own does; ask " \ "through the aggregate's own query instead, or open the target directly" end end end |
.unwrap_shape(owner, type_name, seen = []) ⇒ Object
575 576 577 578 579 580 581 582 583 584 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 575 def self.unwrap_shape(owner, type_name, seen = []) return type_name if owner.nil? # owner couldn't be resolved -- compare by name, same as before this unwrap existed return type_name if Attribute::PRIMITIVES.include?(type_name) return type_name if seen.include?(type_name) # a self-referential VO bottoms out on its own name, not an infinite unwrap shape = owner.value_object(type_name) return type_name unless shape # not this owner's own VO (a reference type, say) -- nothing further to unwrap shape.attributes.map { |a| [a.name, unwrap_shape(owner, a.type.to_s, seen + [type_name]), a.list?, a.optional?] }.sort end |
.validate_assembled!(bluebook) ⇒ Object
EVERY WHOLE-CHAPTER CHECK, IN ONE PLACE — the battery #build
used to run inline, now a pure function of an assembled
Bluebook::Chapter so MetaValidator.judge_deferred! can run
it too, once, on a chapter whose files have ALL loaded (see
#build's own comment for why that split exists at all).
Public, not private_class_method'd, for exactly that second
caller — MetaValidator needs to reach this with no builder
instance in hand, only the chapter judge_deferred! already
read back out of the registry.
288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 288 def self.validate_assembled!(bluebook) # moved to the language: an attribute type is a reference to its Shape, # so an undeclared value object fails reference resolution validate_reference_value_objects!(bluebook.aggregates) validate_correlation_keys!(bluebook.process_managers, bluebook.aggregates) validate_no_bidirectional_references!(bluebook.aggregates) unless MetaValidator.shadow_parsing? validate_event_shapes!(bluebook.aggregates) validate_with_projections!(bluebook.policies, bluebook.process_managers, bluebook.aggregates) end # Every hop AggregateBuilder#seal_query_field recognised and # deferred gets checked for real here — the earliest point a # hop CAN be checked, for exactly the reason # validate_no_bidirectional_references! above already gives: # `Bluebook.new` just stamped `hecks_owner` on every # aggregate, so `Reference#resolve` finally has a chapter to # walk. Before this line every target in the file (including # ones declared ABOVE the aggregate doing the asking) would # have resolved to nil. infer_hop_query_arguments!(bluebook) validate_query_hops!(bluebook) # Same precondition, same reason: a `projects` declaration's # own reference cannot resolve until every aggregate in the # chapter is real and owner-stamped (S12, ADR 0025). validate_projected_fields!(bluebook) end |
.validate_correlation_keys!(process_managers, aggregates) ⇒ Object
correlates_by NAMES A SCALAR, NOW CHECKED RATHER THAN TRUSTED.
ProcessManagerBuilder#validate! already refuses a bare, undotted spelling — a SYNTACTIC guarantee that the declaration cannot leave the question open. It cannot go further: a process manager is built in isolation, before this chapter's aggregates exist to check against. Here, with the whole document assembled, the dotted path is walked for real — against whichever command actually emits an event this process manager reacts to — so a path that still lands on a value object is refused before the runtime ever has to decide what a non-scalar correlation key even means: a saga keys off this value directly, and a value object carries no guaranteed-stable identity to key on the way a scalar does.
A command that does not declare the path's first segment at all is silently skipped, not refused — correlation has two other fallback tiers below the payload dig (a correlation stamp, then the emitting aggregate's own reference key; saga_interpreter/correlation.rb), so an absent field is not this check's business. Only a field that resolves, and resolves to something other than a scalar, is.
972 973 974 975 976 977 978 979 980 981 982 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 972 def self.validate_correlation_keys!(process_managers, aggregates) process_managers.each do |pm| next unless pm.correlates_by reason = correlation_key_violation(pm, aggregates) next unless reason raise ProcessManagerBuilder::InvalidProcessManager, "#{pm.name} correlates_by #{pm.correlates_by.inspect}, but #{reason}" end end |
.validate_event_shapes!(aggregates) ⇒ Object
EVENTS ARE FIRST-CLASS BY CONVENTION, NOT BY DECLARATION (ADR
0025, "events and reactions" — "a domain event is a value
object with its own attributes, not a label"). No new event do ... end construct exists to hand-author and keep in step
with every emitting command by hand — an event's own known
shape IS whichever command(s) declare emits for its name, and
this is the ONE thing that has to hold for that convention to
mean anything: every command that emits a given name has to
agree on what it carries. An event is one fact; a fact does not
carry two different truths depending on who is telling it.
STRUCTURAL fields only (name/type/list/optional) — pattern:/
admits:/default: are refinements ON a field, not a second
claim about what the payload holds, so two emitting commands
are free to differ there without actually disagreeing about
the event's own shape.
374 375 376 377 378 379 380 381 382 383 384 385 386 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 374 def self.validate_event_shapes!(aggregates) event_emitters(aggregates).each do |event_name, pairs| next if pairs.size == 1 shapes = pairs.map { |(owner, command)| event_shape(command, owner_aggregate(owner, aggregates)) }.uniq next if shapes.size == 1 named = pairs.map { |(owner, command)| "#{owner}.#{command.hecks_name}" }.sort raise Malformed, "#{event_name.inspect} is emitted with different shapes by #{named.join(' and ')} — " \ "an event is one fact, and every command that emits it must declare the same fields" end end |
.validate_hop_clause!(aggregate, query, clause) ⇒ Object
799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 799 def self.validate_hop_clause!(aggregate, query, clause) plan = QuerySpecification::HopPath.plan(clause.field, aggregate.attributes) case plan.refusal when :unresolvable # HopPath.plan pushes even an unresolved hop onto `hops` # before reporting this, specifically so `target_name` — # real, known at declaration, independent of whether # `resolve` succeeded — is always here to name. raise Malformed, "#{aggregate.hecks_name}.#{query.hecks_name} asks about #{clause.field}, " \ "which hops to #{plan.hops.last.target_name}, which this chapter never " \ "declares — a hop into an aggregate this chapter cannot see resolves to " \ "nothing, and a where that resolves to nothing matches nothing and " \ "refuses nothing" when :too_deep raise Malformed, "#{aggregate.hecks_name}.#{query.hecks_name} asks about #{clause.field}, " \ "whose hop chain reaches #{QuerySpecification::HopPath::MAX_HOPS} " \ "references deep without landing — a chain this long is refused as a " \ "likely mistake, not a structural limit" end target = plan.hops.last.target validate_hop_tail!(aggregate, query, clause, target, plan.tail) end |
.validate_hop_comparator!(aggregate, query, clause, target, attribute, nested) ⇒ Object
A WHERE hop with an ordered comparator is legitimate ("client whose balance > 500") — AggregateBuilder#seal_ordered_comparator already deferred this exact check for the same reason every other hop check is deferred, and this is where it gets asked, against the hop's TARGET instead of the querying aggregate.
861 862 863 864 865 866 867 868 869 870 871 872 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 861 def self.validate_hop_comparator!(aggregate, query, clause, target, attribute, nested) return unless AggregateBuilder::ORDERED_COMPARATORS.include?(clause.op.to_s.to_sym) return if attribute && QuerySpecification::FieldPath.numeric?(attribute, nested) { |type| target.value_object(type) } held = attribute ? "holds no number" : "is the lifecycle field, which holds text" raise Malformed, "#{aggregate.hecks_name}.#{query.hecks_name} compares #{clause.field} with " \ "#{clause.op} after hopping to #{target.hecks_name}, but the field it lands " \ "on #{held} — an ordered comparison needs a numeric field, and over " \ "anything else the adapters answer differently or not at all" end |
.validate_hop_tail!(aggregate, query, clause, target, tail) ⇒ Object
The same three-way answer seal_query_field gives for its OWN aggregate's fields — landing on a real scalar (fine), landing on a value object (refused by name), or naming nothing at all (refused by name) — asked instead of the hop's TARGET aggregate, since that is whose shape the tail actually has to answer for.
831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 831 def self.validate_hop_tail!(aggregate, query, clause, target, tail) name, *nested = tail.to_s.split(".") attribute = target.attributes.find { |candidate| candidate.name.to_s == name } return validate_hop_comparator!(aggregate, query, clause, target, attribute, nested) if nested.empty? && (attribute || target.lifecycle&.field.to_s == name) return validate_hop_comparator!(aggregate, query, clause, target, attribute, nested) if nested.any? && attribute && QuerySpecification::FieldPath.scalar_leaf?(attribute, nested) { |type| target.value_object(type) } if nested.any? && attribute && !QuerySpecification::FieldPath.leaf_attribute(attribute, nested) { |type| target.value_object(type) }.nil? raise Malformed, "#{aggregate.hecks_name}.#{query.hecks_name} asks about #{clause.field}, " \ "which hops to #{target.hecks_name} and then asks about #{tail}, which " \ "lands on a value object, not a scalar — a dotted query path ends on a " \ "scalar member, or the engines answer it differently" end raise Malformed, "#{aggregate.hecks_name}.#{query.hecks_name} asks about #{clause.field}, " \ "which hops to #{target.hecks_name} and then asks about #{tail}, which " \ "#{target.hecks_name} never declares — a query over a field that does " \ "not exist matches nothing and refuses nothing" end |
.validate_no_bidirectional_references!(aggregates) ⇒ Object
A REFERENCE RING IS NOT A MODELLING CHOICE, IT IS A MISSING ONE — a DDD aggregate is a consistency boundary precisely because something outside it can only ever point IN, by id, never the other way. A caller must be able to reason about one aggregate alone ; a ring back to where it started means no aggregate in it is a boundary anyone can reason about without the rest of the ring, and the whole ring is really one aggregate wearing several names.
Checked at the bluebook level, not inside AggregateBuilder
itself, because seeing a cycle needs every end declared — an
aggregate finishes building long before it can know whether
some later aggregate in the same file points back at it.
ACYCLIC WITHIN A CHAPTER (ADR 0025, "References") — widened
from the direct pair (A -> B -> A) this used to catch alone to
any ring, however long (A -> B -> C -> A), the same DFS
coloring a reference graph needs for any cycle. A cross-chapter
reference is UNREACHABLE here rather than unchecked:
Reference#resolve is scoped to its own chapter by
construction, so a target this chapter never declares is a
dangling name, not an edge — edges.key? below is what keeps
the walk from ever leaving this chapter's own aggregates.
Self-reference stays legal (parent.parent.name for a
hierarchy is real and safe) — excluded the same way the
direct-pair check already excluded it.
661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 661 def self.validate_no_bidirectional_references!(aggregates) edges = aggregates.each_with_object({}) do |aggregate, index| index[aggregate.hecks_name] = aggregate.reference_targets.uniq.reject { |target| target == aggregate.hecks_name } end cycle = find_reference_cycle(edges) return unless cycle ring = "#{cycle.join(' -> ')} -> #{cycle.first}" raise Malformed, "reference cycle: #{ring} — an aggregate points at another by id, and a " \ "ring back to where it started means no aggregate in it is a boundary " \ "anyone can reason about alone ; break the ring, or let one side be found " \ "through a query instead of a reference pointing back" end |
.validate_projected_field!(aggregate, field) ⇒ Object
895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 895 def self.validate_projected_field!(aggregate, field) plan = QuerySpecification::HopPath.plan("#{field.reference}/#{field.remote_field}", aggregate.attributes) if plan.refusal == :unresolvable raise Malformed, "#{aggregate.hecks_name}.projects :#{field.name} reads through :#{field.reference}, " \ "which hops to #{plan.hops.last.target_name}, which this chapter never declares — " \ "a projection through an aggregate this chapter cannot see resolves to nothing" end target = plan.hops.last.target remote_attribute = target.attributes.find { |candidate| candidate.name.to_s == plan.tail } # THE WORKED EXAMPLE ITSELF (ADR 0025) reads through a # LIFECYCLE field — banking's Customer.status is `lifecycle # :status`, never a plain `attribute` — the same fallback # validate_hop_tail! already gives a query's own hop tail. A # lifecycle field is always a plain string by construction ; # nothing further to check once it matches by name. return if remote_attribute.nil? && target.lifecycle&.field.to_s == plan.tail # A PROJECTION MAY CHAIN THROUGH ANOTHER PROJECTION (S12, ADR # 0025's own boundary rule, followed through) — `target`'s # OWN projected fields live in `projected_fields`, a # separate list from `attributes`, so a match there is # invisible to the check above even though it names a real, # always-current, stored field. `Transfer.projects # :source_customer_status, from: :"source.customer_status"` # reads Account's own already-projected `customer_status` # this way — Account is one hop from Customer, Transfer is # one hop from Account, and neither aggregate needs to know # about the other's target two hops away. A projected # field's remote value is always a scalar by construction # (`RebuildSweep.remote_value` never copies a reference, a # value object, or a list), so nothing further to check once # it matches by name — same reasoning the lifecycle # fallback just above already applies. return if remote_attribute.nil? && target.projected_fields.any? { |f| f.name.to_s == plan.tail } unless remote_attribute raise Malformed, "#{aggregate.hecks_name}.projects :#{field.name} reads #{target.hecks_name}'s own " \ "#{plan.tail.inspect}, which #{target.hecks_name} never declares" end return if projectable_scalar?(target, remote_attribute) raise Malformed, "#{aggregate.hecks_name}.projects :#{field.name} reads #{target.hecks_name}'s own " \ "#{plan.tail.inspect}, which is not a scalar — a projected field copies a single " \ "value, never a reference, a value object, or a list" end |
.validate_projected_fields!(bluebook) ⇒ Object
THE TARGET HALF of projects validation (S12, ADR 0025) —
AggregateBuilder#seal_projected_fields already checked the
LOCAL half at declare time (the reference names a real
reference_to on THIS aggregate); this checks the reference
actually resolves to a real aggregate in this chapter, and
that aggregate really declares remote_field as a scalar.
Reuses QuerySpecification::HopPath rather than re-deriving
hop resolution a second way — "reference/remote_field" is
the same single-hop shape a query's own /-spelled hop
resolves, even though projects's own DSL spelling is dotted
(from: :"customer.status"): two constructs, two spellings,
one resolution primitive. A single hop can never reach
HopPath::MAX_HOPS, so :too_deep is structurally unreachable
here and is not special-cased.
889 890 891 892 893 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 889 def self.validate_projected_fields!(bluebook) bluebook.aggregates.each do |aggregate| aggregate.projected_fields.each { |field| validate_projected_field!(aggregate, field) } end end |
.validate_query_hops!(bluebook) ⇒ Object
THE OTHER HALF OF A HOP — AggregateBuilder#seal_query_field recognised the HEAD of a dotted where-field that names one of its own references and deferred it here, unable to check further: it cannot yet resolve what the reference points AT. This runs once every aggregate exists in one chapter, so it can.
Only WHERE clauses ever reach here — a hop on ORDER BY is refused outright, immediately, back in seal_query_field itself (that answer never needed the target's shape).
AN ENTITY'S OWN QUERIES DID reach EntityBuilder#reference_to
(added after this comment first claimed otherwise — S9, ADR
0025) without ever reaching HERE: tier-1 sealing
(AggregateBuilder#query_surfaces) already recognises a hop
on an entity's own field and DEFERS it exactly like an
aggregate's, but nothing ever walked entity queries at tier 2
to check the deferral — a bad hop, or even a well-formed one,
built silently and then matched nothing at runtime
(QueryInterpreter#entity_rows reads an element's fields by
literal hash key, never follows a reference). Refused outright
here instead of taught to follow the hop for real: no corpus
member needs an entity query to cross a reference yet, and a
named refusal beats a runtime that resolves nothing while
looking like it might.
735 736 737 738 739 740 741 742 743 744 745 746 747 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 735 def self.validate_query_hops!(bluebook) bluebook.aggregates.each do |aggregate| aggregate.queries.each do |query| query.wheres.each do |clause| next unless QuerySpecification::HopPath.hop_head?(clause.field, aggregate.attributes) validate_hop_clause!(aggregate, query, clause) end end aggregate.entities.each { |entity| refuse_entity_query_hops!(aggregate, entity) } end end |
.validate_reference_value_objects!(aggregates) ⇒ Object
AN ENTITY COMMAND MAY NOT NAME ITSELF AS ITS ROOT.
That is the whole of what is left here, and it needs saying plainly because the sentence this used to raise — "references must target aggregate heads" — was never what it checked.
CommandBuilder#reference_to sets references ONLY when the target's
bare name equals the owner's ; anything else becomes a reference
ATTRIBUTE. So on an aggregate command references is always a copy of
that aggregate's own name, and looking it up in an index of aggregates
is a TAUTOLOGY — that branch never refused anything and structurally
could not. Verified across all eight golden chapters before deleting it.
On a PIECE's command the owner is the entity, and an entity is not a
head, so what this actually refuses is reference_to <its own name>
written inside entity do … end. A piece is reached THROUGH its
aggregate ; a command on one addresses the aggregate, never the piece.
Reference ATTRIBUTES are the language's business now — offered as the
head's own id and resolved as references, so Aggregate.Reference and
Command.Reference refuse an undeclared head with no predicate at all.
338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 338 def self.validate_reference_value_objects!(aggregates) heads = aggregates.map(&:hecks_name) violations = aggregates.flat_map do |aggregate| aggregate.entities.flat_map do |entity| entity.commands.filter_map do |command| next unless command.references next if heads.include?(command.references.to_s) "#{aggregate.hecks_name}.#{entity.hecks_name}.#{command.hecks_name} names itself as its root" end end end return if violations.empty? raise Malformed, "an entity command is addressed through its aggregate; #{violations.uniq.join('; ')}" end |
.validate_with_projections!(policies, process_managers, aggregates) ⇒ Object
THE "EXPENSIVE HALF" the ADR names: "with: { account: :account }
projecting into a reaction that has no declared contract ...
breaks at dispatch rather than at load." Checked here, now that
validate_event_shapes! (above) guarantees at most one real
shape per event name, and command references being first-class
(Naming.command_ref) means the TARGET side is a real
resolvable command, not a string that might be a typo.
SAME-CHAPTER ONLY, ON PURPOSE — a with: whose source event or
target command lives outside this chapter (an across policy
reacting to another domain's event entirely) is silently left
unchecked rather than refused: there is nothing here yet to
check it against, and "unresolvable" is not the same claim as
"wrong."
A FOR_EACH POLICY'S SOURCE ISN'T THE EVENT AT ALL — a fan-out
with:'s symbols read the QUERY ROW for_each answers
(FreezeAccountsOnSuspension's own comment: "account is the
key the fan-out merges for each row"), which this has no shape
for; the SOURCE half is skipped for those, the TARGET half
(does the dispatched command actually declare the field) still
runs, since that half is true regardless of where the value
came from.
411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 411 def self.validate_with_projections!(policies, process_managers, aggregates) lookup = command_lookup(aggregates) heads = correlation_heads(process_managers) policies.each do |policy| next if policy.with_spec.to_a.empty? source_event = policy.for_each.to_s.empty? ? policy.on_event : nil check_with_spec!(policy.trigger_command, source_event, policy.with_spec, lookup, "#{policy.name}'s trigger", aggregates, heads) end process_managers.each do |pm| pm.handlers.each do |handler| handler.dispatches.each do |dispatch| next if dispatch.with_spec.to_a.empty? check_with_spec!(dispatch.command_name, handler.event_type, dispatch.with_spec, lookup, "#{pm.name}'s dispatch #{dispatch.command_name}", aggregates, heads, pm: pm) end end end end |
.walk_scalar(owner, type_name, segments) ⇒ Object
Walks the remaining dotted segments through nested value objects.
type_name starts as the head attribute's own declared type ; each
step either bottoms out at a real scalar (nil — no violation) or
names why it cannot: still a value object with no more path left,
a value object this domain never declared, a field that value
object does not have, or a segment left over after already
reaching a scalar.
1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 1029 def self.walk_scalar(owner, type_name, segments) if segments.empty? return nil if Attribute::PRIMITIVES.include?(type_name) return "#{type_name} is a value object, not a scalar — name one of its own fields, " \ "e.g. #{type_name.downcase}.value" end return "#{type_name} is already a scalar — #{segments.join('.')} has nothing left to reach" if Attribute::PRIMITIVES.include?(type_name) shape = owner.value_object(type_name) return "#{type_name} is not a value object this domain declares" unless shape segment, *rest = segments attribute = shape.attributes.find { |a| a.name == segment.to_sym } return "#{type_name} has no field #{segment.inspect}" unless attribute return "#{type_name}.#{segment} is a list — a correlation key must name one instance's own field, " \ "not a whole collection" if attribute.list? walk_scalar(owner, attribute.type.to_s, rest) end |
Instance Method Details
#aggregate_impl(name, &block) ⇒ Object
@chapter_named_givens is threaded into every aggregate this
chapter builds — see AggregateBuilder#given's own comment
for the sharing this enables; NOT a new top-level DSL word
itself (an aggregate's own EXISTING given already both
declares locally and write-throughs here as a side effect,
the identical shape EntityBuilder#given's own write-through
to its owner aggregate's pool already takes — no new spelling
for "declare a precondition," one level wider, same word).
RENAMED FROM aggregate — item #13's full metaprogrammed
dispatch (slice 4c). Bootstrap-reachable (every core/attached
chapter's own top-level shape is written with it), so also
named in GenericDispatch::BOOTSTRAP_CALLS_FALLBACK.
94 95 96 97 98 99 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 94 def aggregate_impl(name, &block) @aggregates << AggregateBuilder.build(name, chapter_named_givens: @chapter_named_givens, chapter_pending_givens: @chapter_pending_givens, chapter_entity_named_givens: @chapter_entity_named_givens, chapter_entity_pending_givens: @chapter_entity_pending_givens, &block) end |
#attaches_to_impl(*contexts) ⇒ Object
A SUB-LANGUAGE NAMES WHERE IT LANDS. ADR 0026's own seam: the core
grammar does not name its extension points, so this chapter names
ITSELF onto them instead — the core contexts (e.g. "Query",
"ReadModel") whose own admitted words this chapter's Syntax
aggregate contributes rows for. Variadic, and accumulating across
calls the same reason identified_by/group_by are: nothing here
requires one call to name every context at once.
RENAMED FROM attaches_to — item #13's full metaprogrammed
dispatch (slice 4c). Not bootstrap-reachable (only sub-language
chapters like Paging use it; the CORE chapters never describe
themselves with it).
76 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 76 def attaches_to_impl(*contexts) = (@attaches_to ||= []).concat(contexts.map(&:to_s)) |
#build ⇒ Object
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 130 def build # The chapter is the top of the construct chain — `Bluebook` is a # ROOT, and its constructor stamps every aggregate and read model with # itself as owner, so every `hecks_fqn` below resolves by walking up # to it. No constants are installed at load time : the public door is # a per-boot projection, installed by `Loader.bind_runtime` once a # dispatcher exists to close over (facade/surface.rb). bluebook = Bluebook::Chapter.new(name: @name, version: @version, vision: @vision, aggregates: @aggregates, read_models: @read_models, policies: @aggregates.flat_map(&:policies) + @policies, process_managers: @process_managers, classification: @classification, formerly_known_as: @formerly_known_as, attaches_to: @attaches_to || []) # SAME REASON, SAME GATE — a bare chapter-given may still be # pending (see `AggregateBuilder#pending_chapter_given`) if a # file that would resolve it hasn't loaded yet; resolving now # would see the same incomplete `@chapter_named_givens` # `validate_assembled!` below would. Deferred to # `MetaValidator.judge_deferred!` the same way, and BEFORE # `validate_assembled!` there — nothing downstream should ever # read an unresolved placeholder's fields. resolve_pending_chapter_givens! unless MetaValidator.deferring? resolve_pending_chapter_entity_givens! unless MetaValidator.deferring? # A CHAPTER MAY BE SPLIT ACROSS FILES (see `self.build`'s own # comment). Every check below needs the WHOLE chapter present — # a hop, a projection, a correlation key or an event shape can # equally name a construct declared in a file that has not # loaded yet, and `@aggregates`/`@process_managers` here are # only ever as complete as whatever has loaded SO FAR. So, # exactly like `MetaValidator.call` below, this is skipped # while `MetaValidator.defer` is loading the chapter's files # and run once instead — by `MetaValidator.judge_deferred!`, # against the fully assembled chapter — after the last one # loads. A single-file chapter (still the common case) never # sees `deferring?` true here at all, so its own checks still # run inline, exactly as before. self.class.validate_assembled!(bluebook) unless MetaValidator.deferring? # The language judges the bluebook, in the language. Last, so the # meta-domain sees a fully built IR — the whole-document rules need # every declaration present, which is why they cannot be givens fired # at declaration time. MetaValidator.call(bluebook) end |
#core ⇒ Object
78 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 78 def core = @classification = :core |
#formerly_known_as(value) ⇒ Object
A domain's own identity can change — this names what it used to be, so the storage layer can recognize its own history under the old name instead of minting a brand-new lineage from nothing.
63 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 63 def formerly_known_as(value) = @formerly_known_as = value.to_s |
#generic ⇒ Object
80 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 80 def generic = @classification = :generic |
#policy(name, &block) ⇒ Object
122 123 124 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 122 def policy(name, &block) @policies << PolicyBuilder.build(name, &block) end |
#process_manager(name, &block) ⇒ Object
126 127 128 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 126 def process_manager(name, &block) @process_managers << ProcessManagerBuilder.build(name, &block) end |
#read_model(name, &block) ⇒ Object
read_model is the word (ADR 0025 reverts report — the IR
construct, the registry API, and the docs filename all said
read_model the whole time; no era was ever minted under
report, so this is history and source agreeing again). report
stays answered under MetaValidator.shadow_parsing? (S0a's own
bridge) for the same reason has_many does — frozen era text
that used it must keep booting; live source refuses it, naming
the replacement.
109 110 111 112 113 114 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 109 def read_model(name, &block) # A read model gathers heads from SEVERAL aggregates, so no single head # declares it — the chapter does. Its owner is stamped in `build`, where # the chapter namespace exists. @read_models << ReadModelBuilder.build(name, &block) end |
#report(name, &block) ⇒ Object
116 117 118 119 120 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 116 def report(name, &block) return read_model(name, &block) if MetaValidator.shadow_parsing? raise Malformed, "report is gone — read_model is the word now" end |
#resolve_pending_chapter_entity_givens! ⇒ Object
THE ENTITY-SCOPED ANALOGUE, one level down — see
#resolve_pending_chapter_givens!'s own comment; identical
shape, resolved against @chapter_entity_named_givens instead.
241 242 243 244 245 246 247 248 249 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 241 def resolve_pending_chapter_entity_givens! @chapter_entity_pending_givens.each do |entry| resolved = resolve_pending_chapter_entity_given(entry) entry[:placeholder].description = resolved.description entry[:placeholder].canonical = resolved.canonical entry[:placeholder].predicate = resolved.predicate end @chapter_entity_pending_givens.clear end |
#resolve_pending_chapter_givens! ⇒ Object
THE OTHER HALF OF A CHAPTER-WIDE given REFERENCE —
AggregateBuilder#pending_chapter_given recognised an
unresolved bare reference and deferred it here, unable to
check further: a later file in this SAME chapter might still
declare the real thing. Runs once every file has loaded,
against the now-complete @chapter_named_givens pool — the
IDENTICAL lookup reference_named_chapter_given already does,
just late enough to see every aggregate's own declarations,
not only the ones loaded before the referencing one.
MUTATES each placeholder Given IN PLACE rather than
replacing it — it is already embedded, by Ruby object
reference, in the referencing aggregate's own preconditions
and in any command (same aggregate) that separately
bare-referenced the same description, so there is nothing
downstream holding a second, now-stale copy to update.
Instance-level (not self., unlike validate_assembled!) —
unlike that battery, this needs @chapter_named_givens itself,
which only exists on the builder instance still open for this
chapter (MetaValidator.judge_deferred! reaches it via
registry.bluebook_builder(name), guaranteed already present).
200 201 202 203 204 205 206 207 208 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 200 def resolve_pending_chapter_givens! @chapter_pending_givens.each do |entry| resolved = resolve_pending_chapter_given(entry) entry[:placeholder].description = resolved.description entry[:placeholder].canonical = resolved.canonical entry[:placeholder].predicate = resolved.predicate end @chapter_pending_givens.clear end |
#supporting ⇒ Object
79 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 79 def supporting = @classification = :supporting |
#vision(value) ⇒ Object
54 55 56 57 58 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 54 def vision(value) # moved to the language: Vision invariant, on Chapter.Declare @vision = value end |