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_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 |
# 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 = [] 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.
997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 997 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.
396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 396 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.
444 445 446 447 448 449 450 451 452 453 454 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 444 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
578 579 580 581 582 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 578 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.
464 465 466 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 464 def self.correlation_heads(process_managers) process_managers.filter_map { |pm| pm.correlates_by && pm.correlation_head } end |
.correlation_key_violation(pm, aggregates) ⇒ Object
915 916 917 918 919 920 921 922 923 924 925 926 927 928 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 915 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).
473 474 475 476 477 478 479 480 481 482 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 473 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
938 939 940 941 942 943 944 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 938 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.
493 494 495 496 497 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 493 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.
559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 559 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.
520 521 522 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 520 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
547 548 549 550 551 552 553 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 547 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.
629 630 631 632 633 634 635 636 637 638 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 629 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.
703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 703 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
946 947 948 949 950 951 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 946 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.
543 544 545 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 543 def self.owner_aggregate(owner, aggregates) aggregates.find { |a| a.hecks_name == owner.to_s.split(".").first } end |
.projectable_scalar?(target, attribute) ⇒ Boolean
879 880 881 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 879 def self.projectable_scalar?(target, attribute) !attribute.list? && !attribute.reference? && target.value_object(attribute.type).nil? end |
.reacted_events(pm) ⇒ Object
930 931 932 933 934 935 936 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 930 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
640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 640 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
734 735 736 737 738 739 740 741 742 743 744 745 746 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 734 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
524 525 526 527 528 529 530 531 532 533 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 524 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.
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 237 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.
903 904 905 906 907 908 909 910 911 912 913 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 903 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.
323 324 325 326 327 328 329 330 331 332 333 334 335 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 323 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
748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 748 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.
810 811 812 813 814 815 816 817 818 819 820 821 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 810 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.
780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 780 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.
610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 610 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
844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 844 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 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.
838 839 840 841 842 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 838 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.
684 685 686 687 688 689 690 691 692 693 694 695 696 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 684 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.
287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 287 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.
360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 360 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.
960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 960 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.
87 88 89 90 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 87 def aggregate_impl(name, &block) @aggregates << AggregateBuilder.build(name, chapter_named_givens: @chapter_named_givens, chapter_pending_givens: @chapter_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).
69 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 69 def attaches_to_impl(*contexts) = (@attaches_to ||= []).concat(contexts.map(&:to_s)) |
#build ⇒ Object
121 122 123 124 125 126 127 128 129 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 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 121 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? # 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
71 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 71 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.
56 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 56 def formerly_known_as(value) = @formerly_known_as = value.to_s |
#generic ⇒ Object
73 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 73 def generic = @classification = :generic |
#policy(name, &block) ⇒ Object
113 114 115 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 113 def policy(name, &block) @policies << PolicyBuilder.build(name, &block) end |
#process_manager(name, &block) ⇒ Object
117 118 119 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 117 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.
100 101 102 103 104 105 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 100 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
107 108 109 110 111 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 107 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_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).
190 191 192 193 194 195 196 197 198 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 190 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
72 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 72 def supporting = @classification = :supporting |
#vision(value) ⇒ Object
47 48 49 50 51 |
# File 'lib/hecks/bluebook/dsl/bluebook_builder.rb', line 47 def vision(value) # moved to the language: Vision invariant, on Chapter.Declare @vision = value end |