Class: Hecks::Ports::Persistence::AppendOnly

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

Overview

Makes append-before-projection a port invariant. Adapters retain control of their durable format, but every one must accept the same entry stream and materialize current state from it.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(adapter) ⇒ AppendOnly

Returns a new instance of AppendOnly.



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

def initialize(adapter)
  @adapter = adapter
  required = %i[append project entries]
  missing = required.reject { |method| adapter.respond_to?(method) }
  raise Runtime::WiringError,
        "#{adapter.class} does not implement append-only persistence: #{missing.join(', ')}" unless missing.empty?
end

Instance Attribute Details

#adapterObject (readonly)

Returns the value of attribute adapter.



18
19
20
# File 'lib/hecks/ports/persistence/append_only.rb', line 18

def adapter
  @adapter
end

Instance Method Details

#aggregateObject



20
# File 'lib/hecks/ports/persistence/append_only.rb', line 20

def aggregate = @adapter.aggregate

#all(**opts) ⇒ Object



31
# File 'lib/hecks/ports/persistence/append_only.rb', line 31

def all(**opts) = @adapter.all(**opts)

#append(entry) ⇒ Object



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

def append(entry) = @adapter.append(entry)

#atomic_put(instance, insert_only: false) ⇒ Object



95
96
97
98
99
100
101
102
103
# File 'lib/hecks/ports/persistence/append_only.rb', line 95

def atomic_put(instance, insert_only: false)
  unless capabilities.include?(:atomic_put) && @adapter.respond_to?(:atomic_put)
    raise Runtime::WiringError, "#{@adapter.class} advertises no atomic_put persistence capability"
  end

  entry = Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
  status = @adapter.atomic_put(entry, insert_only: insert_only)
  Outcome.new(status: status, instance: instance)
end

#capabilitiesObject



35
36
37
38
39
# File 'lib/hecks/ports/persistence/append_only.rb', line 35

def capabilities
  return [] unless @adapter.respond_to?(:persistence_capabilities)

  Array(@adapter.persistence_capabilities).map(&:to_sym).freeze
end

#countObject



32
# File 'lib/hecks/ports/persistence/append_only.rb', line 32

def count = @adapter.count

#delete(id) ⇒ Object



105
106
107
108
109
110
111
112
# File 'lib/hecks/ports/persistence/append_only.rb', line 105

def delete(id)
  return false unless find(id)

  entry = Entry.new(operation: "delete", id: id.to_s, state: nil)
  append(entry)
  project(entry)
  true
end

#entriesObject



33
# File 'lib/hecks/ports/persistence/append_only.rb', line 33

def entries = @adapter.entries

#eventsObject

NOT an endless def events = ... if ... — that modifier binds to the WHOLE def, not just its body, so it evaluates against @adapter while @adapter is still nil (class-body time, before initialize ever runs) and silently skips defining the method at all. Found live: nothing in this codebase called AppendOnly#events before Memory got a reset! test that did.



53
54
55
# File 'lib/hecks/ports/persistence/append_only.rb', line 53

def events
  @adapter.events if @adapter.respond_to?(:events)
end

#find(id) ⇒ Object



30
# File 'lib/hecks/ports/persistence/append_only.rb', line 30

def find(id) = @adapter.find(id)

#project(entry) ⇒ Object



65
# File 'lib/hecks/ports/persistence/append_only.rb', line 65

def project(entry) = @adapter.project(entry)

#query_read_model(domain, model, args, bluebook = nil) ⇒ Object



130
131
132
133
134
# File 'lib/hecks/ports/persistence/append_only.rb', line 130

def query_read_model(domain, model, args, bluebook = nil)
  return unless @adapter.respond_to?(:query_read_model)

  @adapter.query_read_model(domain, model, args, bluebook)
end

#record_event(event) ⇒ Object

NOT an endless def record_event = ... if ... — same gotcha as events above, and it bit for real here: this guard evaluated against @adapter at class-body time (nil, always false), so record_event was never defined at all. emission.rb's own repository.record_event(event) if repository.respond_to?(:record_event) therefore never fired for any adapter, ever — every declared emits was computed and reported in registry.event_log (an in-process array, gone at exit) but never durably recorded. Caught because a live tail of a domain's own persisted events found nothing to tail. sqlite_spec.rb/postgres_spec.rb/ postgres_era_spec.rb all call adapter.record_event directly, bypassing this wrapper — which is exactly why no spec noticed.



126
127
128
# File 'lib/hecks/ports/persistence/append_only.rb', line 126

def record_event(event)
  @adapter.record_event(event) if @adapter.respond_to?(:record_event)
end

#recover!Object

An append is durable before a projection is attempted. Replaying the log restores a snapshot/table after a crash in that small window.



59
60
61
62
# File 'lib/hecks/ports/persistence/append_only.rb', line 59

def recover!
  entries.each { |entry| project(entry) }
  self
end

#reset!Object



41
42
43
44
45
# File 'lib/hecks/ports/persistence/append_only.rb', line 41

def reset!
  raise Runtime::WiringError, "append-only adapter cannot reset" unless @adapter.respond_to?(:reset!)

  @adapter.reset!
end

#save(instance, expected_version: nil) ⇒ Object

Returns an Outcome, not a bare Instance — every call site (CommandInterpreter/EntityInterpreter's own step_save, RebuildSweep#refresh) reads it that way.

expected_version: requests optimistic-concurrency CAS — commit only if the stored record's version still matches what THIS instance was read at. It is nil both when a caller explicitly doesn't want CAS (RebuildSweep#refresh's own projection-field touch-up, which has no given to protect) and when the instance is brand new (never read from storage, so instance.version is nil) — both cases fall through to the plain, unconditional project(entry) below, byte-for-byte today's behavior. Only an adapter that both receives a non-nil expected_version AND declares :optimistic_concurrency gets CAS treatment; every other adapter/call site is unaffected.



82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/hecks/ports/persistence/append_only.rb', line 82

def save(instance, expected_version: nil)
  entry = Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
  append(entry)
  if expected_version && capabilities.include?(:optimistic_concurrency)
    saved = @adapter.project(entry, expected_version: expected_version)
    return Outcome.new(status: :stale, instance: instance) if saved.nil?

    return Outcome.new(status: :saved, instance: saved)
  end
  saved = @adapter.project(entry)
  Outcome.new(status: :saved, instance: saved || instance)
end