Class: Hecks::Ports::Persistence::Lineage

Inherits:
Object
  • Object
show all
Defined in:
lib/hecks/ports/persistence/lineage.rb

Overview

Translates a journal entry written under an old shape into the one the current bluebook declares — a renamed attribute, a renamed aggregate, or a field crossing a value-object boundary. Applied wherever entries are read, so replay derives the current head from history that was never rewritten — the port owns the meaning, adapters stay oblivious.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(renames, moves = [], converts = [], drops = [], retypes: [], computes: [], rekeys: [], backfills: [], ancestor_name: nil, ancestor_storage_name: nil) ⇒ Lineage

Returns a new instance of Lineage.



46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/hecks/ports/persistence/lineage.rb', line 46

def initialize(renames, moves = [], converts = [], drops = [], retypes: [], computes: [], rekeys: [],
               backfills: [], ancestor_name: nil, ancestor_storage_name: nil)
  @renames = renames
  @moves = moves
  @converts = converts
  @drops = drops
  @retypes = retypes
  @computes = computes
  @rekeys = rekeys
  @backfills = backfills
  @ancestor_name = ancestor_name
  @ancestor_storage_name = ancestor_storage_name
end

Instance Attribute Details

#ancestor_nameObject (readonly)

ancestor_name is the declared name a rename came from (matches the held bluebook's own aggregate names); ancestor_storage_name is its derived, snake_case file/table name. Both are nil unless the aggregate itself was renamed.



19
20
21
# File 'lib/hecks/ports/persistence/lineage.rb', line 19

def ancestor_name
  @ancestor_name
end

#ancestor_storage_nameObject (readonly)

ancestor_name is the declared name a rename came from (matches the held bluebook's own aggregate names); ancestor_storage_name is its derived, snake_case file/table name. Both are nil unless the aggregate itself was renamed.



19
20
21
# File 'lib/hecks/ports/persistence/lineage.rb', line 19

def ancestor_storage_name
  @ancestor_storage_name
end

Class Method Details

.for(registry, domain, aggregate) ⇒ Object



21
22
23
24
25
26
27
28
# File 'lib/hecks/ports/persistence/lineage.rb', line 21

def self.for(registry, domain, aggregate)
  translation = registry.translations.find do |candidate|
    candidate.domain == domain.to_s && candidate.for_aggregate(aggregate.name)
  end
  return nil unless translation

  from_declared(translation.for_aggregate(aggregate.name), aggregate.name)
end

.from_declared(declared, aggregate_name) ⇒ Object

One SPECIFIC edge's rules for one aggregate — what the mint path uses, where for would happily answer with whichever edge in the registry mentioned the aggregate first.



33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/hecks/ports/persistence/lineage.rb', line 33

def self.from_declared(declared, aggregate_name)
  return nil unless declared

  renamed_aggregate = declared.was && declared.was != aggregate_name.to_s
  ancestor_name = renamed_aggregate ? declared.was : nil
  ancestor_storage_name = renamed_aggregate ? Naming.snake(declared.was) : nil

  new(declared.renames, declared.moves, declared.converts, declared.drops,
      retypes: declared.retypes, computes: declared.computes, rekeys: declared.rekeys,
      backfills: declared.backfills,
      ancestor_name: ancestor_name, ancestor_storage_name: ancestor_storage_name)
end

Instance Method Details

#computes?Boolean

Whether any rule in this edge is a compute — the one rule kind with no in-process implementation at all. An aggregate carrying one refuses to boot anywhere but Postgres, per-rule and by name, before the general drift machinery says anything vaguer.

Returns:

  • (Boolean)


64
# File 'lib/hecks/ports/persistence/lineage.rb', line 64

def computes? = !@computes.empty?

#explains?(path) ⇒ Boolean

Whether this translation names path as an old key it accounts for — the rename, move, or convert it came from, or an explicit drop. path is a bare name ("cost") or a dotted value-object member ("price.currency"); a rule covering the WHOLE top-level attribute (a rename, a top-level move/convert/drop) also covers anything nested under it, since the whole value travels or goes away together. Used to catch a field — or a value object's own member — that vanished (or silently changed type) without anything explaining it, even when some OTHER field is covered.

backfills matches on the WHOLE name only, never a dotted prefix — a backfill names a top-level attribute that is new outright (nothing to be a prefix of on the held side), unlike every rule above it, which explains a path that existed and moved, converted, or vanished.

Returns:

  • (Boolean)


120
121
122
123
124
125
126
127
128
129
130
# File 'lib/hecks/ports/persistence/lineage.rb', line 120

def explains?(path)
  path = path.to_s
  top = path.split(".").first

  @renames.key?(top.to_sym) ||
    @moves.any? { |move| move.from == path || move.from.split(".").first == top } ||
    @converts.any? { |convert| convert.from == path || convert.from.split(".").first == top } ||
    @drops.any? { |drop| drop.to_s == path || drop.to_s.split(".").first == top } ||
    @computes.any? { |compute| compute.from == path || compute.from.split(".").first == top } ||
    @backfills.any? { |backfill| backfill.name.to_s == top }
end

#fills?(path) ⇒ Boolean

THE DESTINATION-SIDE TWIN of explains? above, which only ever asks about a rule's SOURCE. unsafe_additions asks a different question — not "was this vanished path accounted for" but "does an existing record end up with a value here" — and a move or convert whose to: lands a old field inside a BRAND-NEW top-level attribute (weight becoming contents.weight when Contents did not exist before) fills that attribute for an existing record exactly as a backfill would, even though nothing named contents explains any vanished path. compute counts on the same terms explains? already grants it elsewhere in this file — Postgres-only and audited, not actually applied by THIS method, the same gap the vanish side already lives with.

@renames.value? belongs here too, and used to be missing: a bare rename :cost, to: :amount is the plainest possible covering rule there is (translate above applies it unconditionally, no lookup table, no per-record ambiguity — simpler than a move or convert, which both got their fills? entry from the start), and its absence meant unsafe_additions reported the new name as an unexplained required addition on EVERY rename-only edge, the single most common translation shape there is. explains? already checked the source side (@renames.key?); fills? is the symmetric destination-side check that was never added alongside it.

Returns:

  • (Boolean)


157
158
159
160
161
162
163
164
165
# File 'lib/hecks/ports/persistence/lineage.rb', line 157

def fills?(path)
  path = path.to_s

  @renames.value?(path.to_sym) ||
    @moves.any? { |move| move.to.split(".").first == path } ||
    @converts.any? { |convert| convert.to.split(".").first == path } ||
    @computes.any? { |compute| compute.to.split(".").first == path } ||
    @backfills.any? { |backfill| backfill.name.to_s == path }
end

#rekey?Boolean

THE SINGLE SOURCE OF TRUTH for "does this edge rekey this aggregate" — every consumer (coverage_check.rb's identity gate, minter.rb's approval gate, layer_two.rb's audit, head_compiler.rb's SQL compilation) asks THIS, never re-derives it from declared independently. One accessor to change if what a rekey rule means ever needs to change, not four call sites in four files.

Returns:

  • (Boolean)


72
# File 'lib/hecks/ports/persistence/lineage.rb', line 72

def rekey? = !@rekeys.empty?

#rekey_sqlObject

The rekey's own SQL — first-and-only rule, same one-per-aggregate assumption compute makes about its own list where it matters (an edge with more than one is a DSL-level decision, not something this reader arbitrates).



78
# File 'lib/hecks/ports/persistence/lineage.rb', line 78

def rekey_sql = @rekeys.first&.sql

#retype?(held_type, current_type) ⇒ Boolean

Whether a declared retype says the pair of TYPE names means the same shape — a value object or entity whose own name changed with its members intact. Nothing in the stored data carries the type name, so this never moves a value; it only satisfies the era diff's literal type-name comparison.

Returns:

  • (Boolean)


172
173
174
# File 'lib/hecks/ports/persistence/lineage.rb', line 172

def retype?(held_type, current_type)
  @retypes.any? { |retype| retype.from == held_type.to_s && retype.to == current_type.to_s }
end

#translate(entry) ⇒ Object

The reference semantics for the five PORTABLE rule kinds — rename, move, convert, drop, and the aggregate-level was:. retype moves nothing (stored state never carries a type name) and compute is deliberately not applied here: its SQL is its only implementation, so this transform neither imitates nor checks it — the source field passes through untouched, and the audit verifies compute output against the matview alone.



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/hecks/ports/persistence/lineage.rb', line 87

def translate(entry)
  return entry unless entry.save? && entry.state

  # Deep, not shallow: a move or convert reaches INTO a nested
  # value-object hash, and a shallow dup would quietly mutate
  # the caller's copy of the original entry.
  state = deep_dup(entry.state)
  @renames.each { |old_name, new_name| state[new_name] = state.delete(old_name) if state.key?(old_name) }
  @moves.each { |move| apply_move(state, move) }
  @converts.each { |convert| apply_convert(state, convert) }
  @drops.each { |name| apply_drop(state, name) }
  # LAST, and only where nothing already answered — a backfill
  # fills the gap a rename/move/convert left untouched, never
  # overwrites a value that already made it across.
  @backfills.each { |backfill| state[backfill.name] = backfill.default unless state.key?(backfill.name) }
  Entry.new(operation: entry.operation, id: entry.id, state: state, mirrors: entry.mirrors)
end