whodunit-chronicles 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -226
  3. data/LICENSE +1 -1
  4. data/README.md +96 -599
  5. data/exe/whodunit-chronicles +6 -0
  6. data/lib/whodunit/chronicles/chronicler.rb +62 -0
  7. data/lib/whodunit/chronicles/cli.rb +131 -0
  8. data/lib/whodunit/chronicles/errors.rb +7 -33
  9. data/lib/whodunit/chronicles/ledger.rb +69 -0
  10. data/lib/whodunit/chronicles/ledger_entry.rb +143 -0
  11. data/lib/whodunit/chronicles/ledger_factory.rb +66 -0
  12. data/lib/whodunit/chronicles/ledgers/file_ledger.rb +56 -0
  13. data/lib/whodunit/chronicles/ledgers/memory_ledger.rb +29 -0
  14. data/lib/whodunit/chronicles/ledgers/sqlite_ledger.rb +172 -0
  15. data/lib/whodunit/chronicles/version.rb +2 -1
  16. data/lib/whodunit/chronicles.rb +12 -65
  17. data/lib/whodunit-chronicles.rb +0 -1
  18. data/sig/whodunit/chronicles/chronicler.rbs +14 -0
  19. data/sig/whodunit/chronicles/cli.rbs +17 -0
  20. data/sig/whodunit/chronicles/errors.rbs +15 -0
  21. data/sig/whodunit/chronicles/ledger.rbs +13 -0
  22. data/sig/whodunit/chronicles/ledger_entry.rbs +62 -0
  23. data/sig/whodunit/chronicles/ledger_factory.rbs +14 -0
  24. data/sig/whodunit/chronicles/ledgers/file_ledger.rbs +14 -0
  25. data/sig/whodunit/chronicles/ledgers/memory_ledger.rbs +12 -0
  26. data/sig/whodunit/chronicles/ledgers/sqlite_ledger.rbs +30 -0
  27. data/sig/whodunit/chronicles.rbs +5 -0
  28. metadata +40 -326
  29. data/.codeclimate.yml +0 -50
  30. data/.rubocop.yml +0 -93
  31. data/.yardopts +0 -14
  32. data/CODE_OF_CONDUCT.md +0 -132
  33. data/CONTRIBUTING.md +0 -186
  34. data/Rakefile +0 -18
  35. data/docker/mysql/init.sql +0 -33
  36. data/docker/postgres/init.sql +0 -40
  37. data/docker-compose.yml +0 -138
  38. data/examples/images/campaign-performance-analytics.png +0 -0
  39. data/examples/images/candidate-journey-analytics.png +0 -0
  40. data/examples/images/recruitment-funnel-analytics.png +0 -0
  41. data/lib/.gitkeep +0 -0
  42. data/lib/whodunit/chronicles/adapter_loader.rb +0 -69
  43. data/lib/whodunit/chronicles/adapters/mysql.rb +0 -261
  44. data/lib/whodunit/chronicles/adapters/postgresql.rb +0 -278
  45. data/lib/whodunit/chronicles/change_event.rb +0 -201
  46. data/lib/whodunit/chronicles/composite_processor.rb +0 -86
  47. data/lib/whodunit/chronicles/configuration.rb +0 -112
  48. data/lib/whodunit/chronicles/connection.rb +0 -88
  49. data/lib/whodunit/chronicles/persistence.rb +0 -129
  50. data/lib/whodunit/chronicles/processor.rb +0 -127
  51. data/lib/whodunit/chronicles/service.rb +0 -207
  52. data/lib/whodunit/chronicles/stream_adapter.rb +0 -91
  53. data/lib/whodunit/chronicles/table.rb +0 -120
  54. data/whodunit-chronicles.gemspec +0 -79
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'whodunit/chronicles/cli'
5
+
6
+ exit Whodunit::Chronicles::CLI.run(ARGV)
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cdc_core'
4
+
5
+ require_relative 'ledger_entry'
6
+
7
+ module Whodunit
8
+ module Chronicles
9
+ # Minimal CDC::Core processor that records change events into a ledger.
10
+ #
11
+ # Chronicler is intentionally small. It consumes a {CDC::Core::ChangeEvent},
12
+ # converts it into an immutable {LedgerEntry}, and appends that entry into
13
+ # the provided ledger. Scheduling, retries, backpressure, worker pools, and
14
+ # orchestration belong to CDC runtimes outside this gem.
15
+ class Chronicler < CDC::Core::Processor
16
+ # @return [Object] ledger receiving immutable entries
17
+ attr_reader :ledger
18
+
19
+ # Create a chronicler.
20
+ #
21
+ # @param ledger [#append] ledger-like object
22
+ # @param prepare [Boolean] whether to call ledger.prepare! during initialization
23
+ # @param ensure_indexes [Boolean] whether to call ledger.ensure_indexes! during initialization
24
+ # @param clock [#now] clock used when creating ledger entries
25
+ def initialize(ledger:, prepare: true, ensure_indexes: true, clock: Time)
26
+ @ledger = ledger
27
+ @clock = clock
28
+ @ledger.prepare! if prepare && @ledger.respond_to?(:prepare!)
29
+ @ledger.ensure_indexes! if ensure_indexes && @ledger.respond_to?(:ensure_indexes!)
30
+ end
31
+
32
+ # Process one CDC change event and append one ledger entry.
33
+ #
34
+ # @param event [CDC::Core::ChangeEvent] normalized CDC event
35
+ # @return [CDC::Core::ProcessorResult] standardized processor result
36
+ def process(event)
37
+ entry = LedgerEntry.from_change_event(event, clock: @clock)
38
+ partition_for(entry).append(entry)
39
+ CDC::Core::ProcessorResult.success(event)
40
+ rescue StandardError => e
41
+ CDC::Core::ProcessorResult.failure(
42
+ e,
43
+ event: event,
44
+ processor: self.class.name,
45
+ retryable: true
46
+ )
47
+ end
48
+
49
+ private
50
+
51
+ # Resolve the ledger partition for an entry.
52
+ #
53
+ # @param entry [LedgerEntry] entry being appended
54
+ # @return [Object] ledger-like target
55
+ def partition_for(entry)
56
+ return @ledger.partition_for(entry) if @ledger.respond_to?(:partition_for)
57
+
58
+ @ledger
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+ require_relative 'errors'
6
+ require_relative 'ledger_factory'
7
+
8
+ module Whodunit
9
+ module Chronicles
10
+ # Small operational command-line interface for ledger lifecycle tasks.
11
+ #
12
+ # The CLI owns book preparation, migration-like operations, index creation,
13
+ # verification, and status checks. The runtime chronicler only appends
14
+ # entries into a ledger it has been handed.
15
+ class CLI
16
+ # Run a command with argv-style arguments.
17
+ #
18
+ # @param argv [Array<String>] command-line arguments
19
+ # @param out [#puts] output stream
20
+ # @param err [#puts] error stream
21
+ # @return [Integer] process-style exit code
22
+ def self.run(argv, out: $stdout, err: $stderr)
23
+ new(argv: argv, out: out, err: err).run
24
+ end
25
+
26
+ # Create a CLI instance.
27
+ #
28
+ # @param argv [Array<String>] command-line arguments
29
+ # @param out [#puts] output stream
30
+ # @param err [#puts] error stream
31
+ def initialize(argv:, out:, err:)
32
+ @argv = argv.dup
33
+ @out = out
34
+ @err = err
35
+ end
36
+
37
+ # Execute the requested command.
38
+ #
39
+ # @return [Integer] process-style exit code
40
+ def run
41
+ return usage(0) if @argv.empty? || @argv.first == 'help'
42
+
43
+ namespace = @argv.shift
44
+ command = @argv.shift
45
+ config_path = @argv.shift
46
+ options = @argv.dup
47
+ return usage(1) unless namespace == 'ledger' && command && config_path
48
+
49
+ ledger = LedgerFactory.build(load_config(config_path))
50
+ execute_ledger_command(ledger, command, options)
51
+ rescue StandardError => e
52
+ @err.puts(e.message)
53
+ 1
54
+ end
55
+
56
+ private
57
+
58
+ # Execute a ledger lifecycle command.
59
+ def execute_ledger_command(ledger, command, options)
60
+ case command
61
+ when 'prepare'
62
+ return usage(1) unless options.empty?
63
+
64
+ ensure_supported!(ledger, :prepare!)
65
+ ledger.prepare!
66
+ @out.puts('prepared')
67
+ when 'migrate'
68
+ return usage(1) unless options.empty?
69
+
70
+ ensure_supported!(ledger, :migrate!)
71
+ ledger.migrate!
72
+ @out.puts('migrated')
73
+ when 'ensure-indexes', 'indexes'
74
+ return usage(1) unless options.empty?
75
+
76
+ ensure_supported!(ledger, :ensure_indexes!)
77
+ ledger.ensure_indexes!
78
+ @out.puts('indexes ensured')
79
+ when 'verify'
80
+ return usage(1) unless options.empty?
81
+
82
+ ensure_supported!(ledger, :verify)
83
+ raise LedgerError, 'ledger verification failed' unless ledger.verify
84
+
85
+ @out.puts('verified')
86
+ when 'status'
87
+ return usage(1) unless options.empty? || options == ['--json']
88
+
89
+ ensure_supported!(ledger, :status)
90
+ @out.puts(format_status(ledger.status, options))
91
+ else
92
+ return usage(1)
93
+ end
94
+ 0
95
+ end
96
+
97
+ # Load YAML configuration from disk.
98
+ def load_config(path)
99
+ YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false) || {}
100
+ end
101
+
102
+ # Format status for humans.
103
+ def format_status(status, options)
104
+ return JSON.pretty_generate(status) if options == ['--json']
105
+
106
+ status.map { |key, value| "#{key}: #{value.inspect}" }.join("\n")
107
+ end
108
+
109
+ # Ensure a ledger supports an operational method.
110
+ def ensure_supported!(ledger, method_name)
111
+ return true if ledger.respond_to?(method_name)
112
+
113
+ raise LedgerError, "ledger does not support #{method_name}"
114
+ end
115
+
116
+ # Print usage information.
117
+ def usage(code)
118
+ @out.puts(<<~TEXT)
119
+ Usage:
120
+ whodunit-chronicles ledger prepare CONFIG
121
+ whodunit-chronicles ledger migrate CONFIG
122
+ whodunit-chronicles ledger ensure-indexes CONFIG
123
+ whodunit-chronicles ledger verify CONFIG
124
+ whodunit-chronicles ledger status CONFIG
125
+ whodunit-chronicles ledger status CONFIG --json
126
+ TEXT
127
+ code
128
+ end
129
+ end
130
+ end
131
+ end
@@ -2,42 +2,16 @@
2
2
 
3
3
  module Whodunit
4
4
  module Chronicles
5
- # Base error class for all whodunit-chronicles errors.
6
- #
7
- # Rescuing this class catches every error raised by the gem:
8
- #
9
- # begin
10
- # Whodunit::Chronicles.service.start
11
- # rescue Whodunit::Chronicles::Error => e
12
- # logger.error("Chronicles failure: #{e.message}")
13
- # end
5
+ # Base exception for whodunit-chronicles.
14
6
  class Error < StandardError; end
15
7
 
16
- # Raised when the gem is configured with invalid or missing settings.
17
- #
18
- # @example
19
- # Whodunit::Chronicles.configure do |c|
20
- # c.adapter = :unknown_db # => raises ConfigurationError
21
- # end
22
- class ConfigurationError < Error; end
23
-
24
- # Raised when a required database driver gem is not installed.
25
- #
26
- # @example message
27
- # "Could not load the 'pg' gem required for the postgresql adapter.
28
- # Add `gem 'pg', '~> 1.5'` to your Gemfile."
29
- class AdapterLoadError < Error; end
30
-
31
- # Raised when a streaming or replication connection fails.
32
- class ConnectionError < Error; end
8
+ # Base exception for ledger-related failures.
9
+ class LedgerError < Error; end
33
10
 
34
- # Raised when processing a change event fails.
35
- class ProcessingError < Error; end
11
+ # Raised when an append operation fails before a result can be returned.
12
+ class AppendError < LedgerError; end
36
13
 
37
- # Raised when writing an audit record to the audit database fails.
38
- class PersistenceError < Error; end
39
-
40
- # Raised when replication related error occurs
41
- class ReplicationError < Error; end
14
+ # Raised when ledger configuration cannot be built.
15
+ class ConfigurationError < Error; end
42
16
  end
43
17
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whodunit
4
+ module Chronicles
5
+ # Base class for append-only audit ledgers.
6
+ #
7
+ # Ledger is the minimal storage contract consumed by {Chronicler}. Concrete
8
+ # ledgers only need to implement {#append}; preparation, indexing, and
9
+ # partitioning are optional capabilities with safe defaults.
10
+ class Ledger
11
+ # Prepare backing storage if the ledger supports schema creation.
12
+ #
13
+ # @return [Ledger] this ledger
14
+ def prepare!
15
+ self
16
+ end
17
+
18
+ # Ensure useful indexes if the ledger supports index management.
19
+ #
20
+ # @return [Ledger] this ledger
21
+ def ensure_indexes!
22
+ self
23
+ end
24
+
25
+ # Apply storage evolution steps if the ledger supports migrations.
26
+ #
27
+ # @return [Ledger] this ledger
28
+ def migrate!
29
+ prepare!
30
+ ensure_indexes!
31
+ self
32
+ end
33
+
34
+ # Verify that the ledger is operational.
35
+ #
36
+ # @return [Boolean] true when the ledger appears usable
37
+ def verify
38
+ true
39
+ end
40
+
41
+ # Return lightweight operational status for this ledger.
42
+ #
43
+ # @return [Hash<Symbol, Object>] ledger status
44
+ def status
45
+ { adapter: self.class.name, ready: verify }
46
+ end
47
+
48
+ # Return the target ledger for an entry.
49
+ #
50
+ # Partition-aware ledgers override this method. Simple ledgers return
51
+ # themselves.
52
+ #
53
+ # @param entry [LedgerEntry] entry being appended
54
+ # @return [Ledger] ledger that should receive the entry
55
+ def partition_for(_entry)
56
+ self
57
+ end
58
+
59
+ # Append an immutable entry to the ledger.
60
+ #
61
+ # @param entry [LedgerEntry] entry to persist
62
+ # @raise [NotImplementedError] when not implemented by a concrete ledger
63
+ # @return [Object] implementation-defined result
64
+ def append(_entry)
65
+ raise NotImplementedError, "#{self.class} must implement #append"
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cdc_core'
4
+
5
+ module Whodunit
6
+ module Chronicles
7
+ # Immutable audit-domain representation of one CDC::Core::ChangeEvent.
8
+ class LedgerEntry < Data.define(
9
+ :event_id,
10
+ :occurred_at,
11
+ :recorded_at,
12
+ :namespace,
13
+ :entity,
14
+ :identity,
15
+ :operation,
16
+ :actor,
17
+ :changes,
18
+ :metadata,
19
+ :payload
20
+ )
21
+ # Build a ledger entry from a normalized CDC change event.
22
+ #
23
+ # @param event [CDC::Core::ChangeEvent] normalized CDC event
24
+ # @param clock [#now] clock used for recorded_at and fallback timestamps
25
+ # @return [LedgerEntry] immutable ledger entry
26
+ # @raise [TypeError] if event is not a CDC::Core::ChangeEvent
27
+ def self.from_change_event(event, clock: Time)
28
+ validate_change_event!(event)
29
+
30
+ new(
31
+ event_id: event_id_for(event),
32
+ occurred_at: event.occurred_at || clock.now,
33
+ recorded_at: clock.now,
34
+ namespace: namespace_for(event),
35
+ entity: entity_for(event),
36
+ identity: event.primary_key,
37
+ operation: event.operation,
38
+ actor: actor_for(event),
39
+ changes: changes_for(event),
40
+ metadata: metadata_for(event),
41
+ payload: event.to_h
42
+ )
43
+ end
44
+
45
+ class << self
46
+ alias_method :from_event, :from_change_event
47
+ end
48
+
49
+ # Convert this entry to a Hash keyed by attribute name.
50
+ #
51
+ # @return [Hash<Symbol, Object>] entry attributes
52
+ def to_h
53
+ members.to_h { |name| [name, public_send(name)] }
54
+ end
55
+
56
+ # Build a stable ordering identity for per-record ordering.
57
+ #
58
+ # @return [String] namespace/entity/identity tuple rendered as a string
59
+ def ordering_identity
60
+ [namespace, entity, identity].compact.join(':')
61
+ end
62
+
63
+ # Verify the event type.
64
+ #
65
+ # @param event [Object] candidate event
66
+ # @return [true]
67
+ def self.validate_change_event!(event)
68
+ return true if event.is_a?(CDC::Core::ChangeEvent)
69
+
70
+ raise TypeError, "expected CDC::Core::ChangeEvent, got #{event.class}"
71
+ end
72
+ private_class_method :validate_change_event!
73
+
74
+ # Derive a stable event id from CDC source coordinates.
75
+ #
76
+ # @param event [CDC::Core::ChangeEvent] normalized CDC event
77
+ # @return [String] deterministic event id
78
+ def self.event_id_for(event)
79
+ require 'digest'
80
+ Digest::SHA256.hexdigest(Marshal.dump(event.to_h))
81
+ end
82
+ private_class_method :event_id_for
83
+
84
+ # Source-neutral namespace for the changed entity.
85
+ #
86
+ # @param event [CDC::Core::ChangeEvent] normalized CDC event
87
+ # @return [String] namespace value
88
+ def self.namespace_for(event)
89
+ event.schema
90
+ end
91
+ private_class_method :namespace_for
92
+
93
+ # Source-neutral entity name for the changed record collection.
94
+ #
95
+ # @param event [CDC::Core::ChangeEvent] normalized CDC event
96
+ # @return [String] entity value
97
+ def self.entity_for(event)
98
+ event.table
99
+ end
100
+ private_class_method :entity_for
101
+
102
+ # Extract actor details from event metadata.
103
+ #
104
+ # @param event [CDC::Core::ChangeEvent] normalized CDC event
105
+ # @return [Object, nil] actor metadata
106
+ def self.actor_for(event)
107
+ event.metadata[:actor] || event.metadata[:whodunit]
108
+ end
109
+ private_class_method :actor_for
110
+
111
+ # Extract column changes in a persistence-friendly shape.
112
+ #
113
+ # @param event [CDC::Core::ChangeEvent] normalized CDC event
114
+ # @return [Array<Hash>] changed column records
115
+ def self.changes_for(event)
116
+ event.changes.map(&:to_h)
117
+ end
118
+ private_class_method :changes_for
119
+
120
+ # Extract ledger metadata from CDC event metadata and source coordinates.
121
+ #
122
+ # @param event [CDC::Core::ChangeEvent] normalized CDC event
123
+ # @return [Hash] metadata hash
124
+ def self.metadata_for(event)
125
+ event.metadata.to_h.merge(
126
+ 'transaction_id' => event.transaction_id,
127
+ 'source_position' => source_position_for(event),
128
+ 'sequence_number' => event.sequence_number
129
+ ).compact
130
+ end
131
+ private_class_method :metadata_for
132
+
133
+ # Source-neutral position for checkpoint/replay correlation.
134
+ #
135
+ # @param event [CDC::Core::ChangeEvent] normalized CDC event
136
+ # @return [String, nil] source position
137
+ def self.source_position_for(event)
138
+ event.commit_lsn
139
+ end
140
+ private_class_method :source_position_for
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+ require_relative 'ledgers/memory_ledger'
5
+ require_relative 'ledgers/file_ledger'
6
+ require_relative 'ledgers/sqlite_ledger'
7
+
8
+ module Whodunit
9
+ module Chronicles
10
+ # Builds ledger instances from simple configuration hashes.
11
+ class LedgerFactory
12
+ # Build a ledger from configuration.
13
+ #
14
+ # @param config [Hash] configuration hash
15
+ # @return [Ledger] configured ledger
16
+ def self.build(config)
17
+ new(config).build
18
+ end
19
+
20
+ # Create a factory.
21
+ #
22
+ # @param config [Hash] configuration hash
23
+ def initialize(config)
24
+ @config = stringify_keys(config)
25
+ end
26
+
27
+ # Build the configured ledger.
28
+ #
29
+ # @return [Ledger] configured ledger
30
+ def build
31
+ ledger = stringify_keys(@config.fetch('ledger', @config))
32
+ adapter = required_value(ledger, 'adapter')
33
+
34
+ case adapter
35
+ when 'memory'
36
+ Ledgers::MemoryLedger.new
37
+ when 'file'
38
+ Ledgers::FileLedger.new(path: required_value(ledger, 'path'))
39
+ when 'sqlite'
40
+ Ledgers::SQLiteLedger.new(path: required_value(ledger, 'path'), table_name: ledger.fetch('table_name', Ledgers::SQLiteLedger::DEFAULT_TABLE))
41
+ else
42
+ raise ConfigurationError, "unsupported ledger adapter: #{adapter.inspect}"
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # Convert hash keys to strings recursively.
49
+ def stringify_keys(value)
50
+ case value
51
+ when Hash
52
+ value.to_h { |key, nested| [key.to_s, stringify_keys(nested)] }
53
+ else
54
+ value
55
+ end
56
+ end
57
+
58
+ # Fetch a required config value with a user-facing error.
59
+ def required_value(config, key)
60
+ config.fetch(key)
61
+ rescue KeyError
62
+ raise ConfigurationError, "missing ledger #{key}"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require_relative '../ledger'
6
+
7
+ module Whodunit
8
+ module Chronicles
9
+ module Ledgers
10
+ # Append-only newline-delimited JSON ledger for simple durable local storage.
11
+ class FileLedger < Ledger
12
+ # @return [String] path to the NDJSON ledger file
13
+ attr_reader :path
14
+
15
+ # Create a file-backed ledger.
16
+ #
17
+ # @param path [String] path to the NDJSON ledger file
18
+ def initialize(path:)
19
+ @path = path.to_s
20
+ end
21
+
22
+ # Ensure the parent directory and ledger file exist.
23
+ #
24
+ # @return [FileLedger] this ledger
25
+ def prepare!
26
+ directory = File.dirname(path)
27
+ FileUtils.mkdir_p(directory) unless directory == '.'
28
+ FileUtils.touch(path)
29
+ self
30
+ end
31
+
32
+ # Append one entry as one JSON line.
33
+ #
34
+ # @param entry [LedgerEntry] entry to append
35
+ # @return [LedgerEntry] appended entry
36
+ def append(entry)
37
+ File.open(path, 'ab') do |file|
38
+ file.flock(File::LOCK_EX)
39
+ file.write(JSON.generate(entry.to_h))
40
+ file.write("\n")
41
+ end
42
+ entry
43
+ end
44
+
45
+ # Read all entries from the file as hashes.
46
+ #
47
+ # @return [Array<Hash>] decoded ledger lines
48
+ def entries
49
+ return [] unless File.exist?(path)
50
+
51
+ File.readlines(path, chomp: true).reject(&:empty?).map { |line| JSON.parse(line) }
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../ledger'
4
+
5
+ module Whodunit
6
+ module Chronicles
7
+ module Ledgers
8
+ # In-memory append-only ledger for tests, examples, and small scripts.
9
+ class MemoryLedger < Ledger
10
+ # @return [Array<LedgerEntry>] appended entries in insertion order
11
+ attr_reader :entries
12
+
13
+ # Create an empty in-memory ledger.
14
+ def initialize
15
+ @entries = []
16
+ end
17
+
18
+ # Append an entry to memory.
19
+ #
20
+ # @param entry [LedgerEntry] entry to append
21
+ # @return [LedgerEntry] appended entry
22
+ def append(entry)
23
+ @entries << entry
24
+ entry
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end