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,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require_relative '../errors'
6
+ require_relative '../ledger'
7
+
8
+ module Whodunit
9
+ module Chronicles
10
+ module Ledgers
11
+ # SQLite-backed embedded durable ledger.
12
+ #
13
+ # SQLiteLedger is the default solid local book. It can create its table,
14
+ # create indexes, report status, and append immutable ledger entries. The
15
+ # sqlite3 gem is loaded lazily only when a connection is not injected.
16
+ class SQLiteLedger < Ledger
17
+ # Default SQLite table for entries.
18
+ DEFAULT_TABLE = 'whodunit_chronicles_entries'
19
+
20
+ # @return [String] path to the SQLite database file
21
+ attr_reader :path
22
+
23
+ # @return [String] table receiving entries
24
+ attr_reader :table_name
25
+
26
+ # Create a SQLite-backed ledger.
27
+ #
28
+ # @param path [String] path to the SQLite database file
29
+ # @param table_name [String] table receiving entries
30
+ # @param connection [Object, nil] optional SQLite-compatible connection
31
+ def initialize(path:, table_name: DEFAULT_TABLE, connection: nil)
32
+ @path = path.to_s
33
+ @table_name = table_name.to_s
34
+ @connection = connection
35
+ end
36
+
37
+ # Create the entries table if needed.
38
+ #
39
+ # @return [SQLiteLedger] this ledger
40
+ def prepare!
41
+ connection.execute(<<~SQL)
42
+ CREATE TABLE IF NOT EXISTS #{quoted_table_name} (
43
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
44
+ event_id TEXT NOT NULL,
45
+ occurred_at TEXT NOT NULL,
46
+ recorded_at TEXT NOT NULL,
47
+ namespace TEXT,
48
+ entity TEXT,
49
+ identity TEXT,
50
+ operation TEXT NOT NULL,
51
+ actor TEXT,
52
+ changes TEXT,
53
+ metadata TEXT,
54
+ payload TEXT NOT NULL
55
+ )
56
+ SQL
57
+ self
58
+ end
59
+
60
+ # Create indexes useful for audit lookup and de-duplication.
61
+ #
62
+ # @return [SQLiteLedger] this ledger
63
+ def ensure_indexes!
64
+ connection.execute("CREATE UNIQUE INDEX IF NOT EXISTS #{index_name(:event_id)} ON #{quoted_table_name} (event_id)")
65
+ connection.execute("CREATE INDEX IF NOT EXISTS #{index_name(:entity)} ON #{quoted_table_name} (namespace, entity)")
66
+ connection.execute("CREATE INDEX IF NOT EXISTS #{index_name(:occurred_at)} ON #{quoted_table_name} (occurred_at)")
67
+ self
68
+ end
69
+
70
+ # Append one ledger entry.
71
+ #
72
+ # @param entry [LedgerEntry] entry to append
73
+ # @raise [AppendError] when SQLite rejects a duplicate event_id
74
+ # @return [LedgerEntry] appended entry
75
+ def append(entry)
76
+ connection.execute(<<~SQL, bind_values(entry))
77
+ INSERT INTO #{quoted_table_name} (
78
+ event_id, occurred_at, recorded_at, namespace, entity,
79
+ identity, operation, actor, changes, metadata, payload
80
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
81
+ SQL
82
+ entry
83
+ rescue StandardError => e
84
+ raise unless sqlite_constraint_error?(e)
85
+
86
+ raise AppendError, "duplicate ledger event_id: #{entry.event_id}"
87
+ end
88
+
89
+ # Return lightweight operational status for this ledger.
90
+ #
91
+ # @return [Hash<Symbol, Object>] ledger status
92
+ def status
93
+ {
94
+ adapter: 'sqlite',
95
+ path: path,
96
+ table_name: table_name,
97
+ prepared: prepared?,
98
+ entries: prepared? ? count_entries : nil
99
+ }
100
+ end
101
+
102
+ private
103
+
104
+ # Return the SQLite connection, creating it lazily when required.
105
+ def connection
106
+ @connection ||= begin
107
+ require 'sqlite3'
108
+ SQLite3::Database.new(path)
109
+ end
110
+ end
111
+
112
+ # Build bind values for one entry.
113
+ def bind_values(entry)
114
+ [
115
+ entry.event_id,
116
+ serialize_time(entry.occurred_at),
117
+ serialize_time(entry.recorded_at),
118
+ entry.namespace,
119
+ entry.entity,
120
+ JSON.generate(entry.identity),
121
+ entry.operation.to_s,
122
+ JSON.generate(entry.actor),
123
+ JSON.generate(entry.changes),
124
+ JSON.generate(entry.metadata),
125
+ JSON.generate(entry.payload)
126
+ ]
127
+ end
128
+
129
+ # Serialize time-like objects consistently.
130
+ def serialize_time(value)
131
+ return value.iso8601 if value.respond_to?(:iso8601)
132
+
133
+ value.to_s
134
+ end
135
+
136
+ # Determine whether the entries table exists.
137
+ def prepared?
138
+ rows = connection.execute(
139
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
140
+ [table_name]
141
+ )
142
+ !rows.empty?
143
+ end
144
+
145
+ # Count persisted entries.
146
+ def count_entries
147
+ connection.execute("SELECT COUNT(*) FROM #{quoted_table_name}").first.first
148
+ end
149
+
150
+ # Return whether an error is SQLite's unique constraint failure.
151
+ def sqlite_constraint_error?(error)
152
+ !!(defined?(SQLite3::ConstraintException) && error.is_a?(SQLite3::ConstraintException))
153
+ end
154
+
155
+ # Quote a SQLite identifier.
156
+ def quote_identifier(identifier)
157
+ %("#{identifier.to_s.gsub('"', '""')}")
158
+ end
159
+
160
+ # Quote the configured table name.
161
+ def quoted_table_name
162
+ @quoted_table_name ||= table_name.split('.').map { |part| quote_identifier(part) }.join('.')
163
+ end
164
+
165
+ # Build a safe index name from the table and suffix.
166
+ def index_name(suffix)
167
+ quote_identifier("#{table_name.gsub(/\W+/, '_')}_#{suffix}_idx")
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Whodunit
4
4
  module Chronicles
5
- VERSION = '0.3.0'
5
+ # Gem version.
6
+ VERSION = '0.4.0'
6
7
  end
7
8
  end
@@ -1,74 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'concurrent-ruby'
4
- require 'dry/configurable'
5
- require 'dry/logger'
3
+ require 'cdc_core'
6
4
 
7
5
  require_relative 'chronicles/version'
8
- require_relative 'chronicles/configuration'
9
- require_relative 'chronicles/change_event'
10
- require_relative 'chronicles/stream_adapter'
11
- require_relative 'chronicles/connection'
12
- require_relative 'chronicles/table'
13
- require_relative 'chronicles/persistence'
14
- require_relative 'chronicles/processor'
15
- require_relative 'chronicles/service'
16
-
17
6
  require_relative 'chronicles/errors'
18
- require_relative 'chronicles/adapter_loader'
19
- require_relative 'chronicles/composite_processor'
20
-
7
+ require_relative 'chronicles/ledger'
8
+ require_relative 'chronicles/ledger_entry'
9
+ require_relative 'chronicles/chronicler'
10
+ require_relative 'chronicles/ledgers/memory_ledger'
11
+ require_relative 'chronicles/ledgers/file_ledger'
12
+ require_relative 'chronicles/ledgers/sqlite_ledger'
13
+ require_relative 'chronicles/ledger_factory'
14
+ require_relative 'chronicles/cli'
15
+
16
+ # Namespace for lightweight attribution and audit ecosystem libraries.
21
17
  module Whodunit
22
- # Chronicles - The complete historical record of `whodunit did what?` data
23
- #
24
- # While Whodunit tracks who made changes, Chronicles captures what changed
25
- # by streaming database events into comprehensive audit trails with zero
26
- # Rails application overhead.
18
+ # Canonical CDC audit sink namespace.
27
19
  module Chronicles
28
- extend Dry::Configurable
29
-
30
- # Configuration settings
31
- setting :logger, default: Dry::Logger.new
32
- setting :database_url, default: ENV.fetch('DATABASE_URL', nil)
33
- setting :audit_database_url, default: ENV.fetch('AUDIT_DATABASE_URL', nil)
34
- setting :adapter, default: :postgresql
35
- # PostgreSQL-specific settings
36
- setting :publication_name, default: 'whodunit_audit'
37
- setting :replication_slot_name, default: 'whodunit_audit_slot'
38
- # MySQL-specific settings
39
- setting :mysql_server_id, default: 1001
40
- setting :batch_size, default: 100
41
- setting :max_retry_attempts, default: 3
42
- setting :retry_delay, default: 5
43
-
44
- # Configure Chronicles
45
- #
46
- # @example
47
- # Whodunit::Chronicles.configure do |config|
48
- # config.database_url = "postgresql://localhost/myapp"
49
- # config.audit_database_url = "postgresql://localhost/myapp_audit"
50
- # config.adapter = :postgresql
51
- # # OR for MySQL:
52
- # config.adapter = :mysql
53
- # config.mysql_server_id = 1001
54
- # end
55
- def self.configure
56
- yield(config) if block_given?
57
- config
58
- end
59
-
60
- # Get the configured logger
61
- #
62
- # @return [Dry::Logger]
63
- def self.logger
64
- config.logger
65
- end
66
-
67
- # Start the audit streaming service
68
- #
69
- # @return [Service]
70
- def self.start
71
- Service.new.start
72
- end
73
20
  end
74
21
  end
@@ -1,4 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Main gem entry point
4
3
  require_relative 'whodunit/chronicles'
@@ -0,0 +1,14 @@
1
+ module Whodunit
2
+ module Chronicles
3
+ class Chronicler < ::CDC::Core::Processor
4
+ attr_reader ledger: untyped
5
+
6
+ def initialize: (ledger: untyped, ?prepare: bool, ?ensure_indexes: bool, ?clock: untyped) -> void
7
+ def process: (::CDC::Core::ChangeEvent event) -> ::CDC::Core::ProcessorResult
8
+
9
+ private
10
+
11
+ def partition_for: (LedgerEntry entry) -> untyped
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ module Whodunit
2
+ module Chronicles
3
+ class CLI
4
+ def self.run: (Array[String] argv, ?out: untyped, ?err: untyped) -> Integer
5
+ def initialize: (argv: Array[String], out: untyped, err: untyped) -> void
6
+ def run: () -> Integer
7
+
8
+ private
9
+
10
+ def execute_ledger_command: (untyped ledger, String command, Array[String] options) -> Integer
11
+ def load_config: (String path) -> Hash[untyped, untyped]
12
+ def format_status: (Hash[untyped, untyped] status, Array[String] options) -> String
13
+ def ensure_supported!: (untyped ledger, Symbol method_name) -> true
14
+ def usage: (Integer code) -> Integer
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ module Whodunit
2
+ module Chronicles
3
+ class Error < StandardError
4
+ end
5
+
6
+ class LedgerError < Error
7
+ end
8
+
9
+ class AppendError < LedgerError
10
+ end
11
+
12
+ class ConfigurationError < Error
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ module Whodunit
2
+ module Chronicles
3
+ class Ledger
4
+ def prepare!: () -> self
5
+ def ensure_indexes!: () -> self
6
+ def migrate!: () -> self
7
+ def verify: () -> bool
8
+ def status: () -> Hash[Symbol, untyped]
9
+ def partition_for: (LedgerEntry entry) -> Ledger
10
+ def append: (LedgerEntry entry) -> untyped
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,62 @@
1
+ module Whodunit
2
+ module Chronicles
3
+ class LedgerEntry < Data
4
+ attr_reader event_id: String
5
+ attr_reader occurred_at: untyped
6
+ attr_reader recorded_at: untyped
7
+ attr_reader namespace: String
8
+ attr_reader entity: String
9
+ attr_reader identity: untyped
10
+ attr_reader operation: Symbol
11
+ attr_reader actor: untyped
12
+ attr_reader changes: untyped
13
+ attr_reader metadata: Hash[untyped, untyped]
14
+ attr_reader payload: Hash[untyped, untyped]
15
+
16
+ def initialize: (
17
+ event_id: String,
18
+ occurred_at: untyped,
19
+ recorded_at: untyped,
20
+ namespace: String?,
21
+ entity: String?,
22
+ identity: untyped,
23
+ operation: Symbol | String,
24
+ actor: untyped,
25
+ changes: untyped,
26
+ metadata: Hash[untyped, untyped],
27
+ payload: Hash[untyped, untyped]
28
+ ) -> void
29
+
30
+ def self.new: (
31
+ event_id: String,
32
+ occurred_at: untyped,
33
+ recorded_at: untyped,
34
+ namespace: String?,
35
+ entity: String?,
36
+ identity: untyped,
37
+ operation: Symbol | String,
38
+ actor: untyped,
39
+ changes: untyped,
40
+ metadata: Hash[untyped, untyped],
41
+ payload: Hash[untyped, untyped]
42
+ ) -> LedgerEntry
43
+
44
+ def self.from_change_event: (::CDC::Core::ChangeEvent event, ?clock: untyped) -> LedgerEntry
45
+ def self.from_event: (::CDC::Core::ChangeEvent event, ?clock: untyped) -> LedgerEntry
46
+ def to_h: () -> Hash[Symbol, untyped]
47
+ def ordering_identity: () -> String
48
+ def members: () -> Array[Symbol]
49
+
50
+ private
51
+
52
+ def self.validate_change_event!: (::Object event) -> true
53
+ def self.event_id_for: (::CDC::Core::ChangeEvent event) -> String
54
+ def self.namespace_for: (::CDC::Core::ChangeEvent event) -> String?
55
+ def self.entity_for: (::CDC::Core::ChangeEvent event) -> String?
56
+ def self.actor_for: (::CDC::Core::ChangeEvent event) -> untyped
57
+ def self.changes_for: (::CDC::Core::ChangeEvent event) -> untyped
58
+ def self.metadata_for: (::CDC::Core::ChangeEvent event) -> Hash[untyped, untyped]
59
+ def self.source_position_for: (::CDC::Core::ChangeEvent event) -> untyped
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,14 @@
1
+ module Whodunit
2
+ module Chronicles
3
+ class LedgerFactory
4
+ def self.build: (Hash[untyped, untyped] config) -> Ledger
5
+ def initialize: (Hash[untyped, untyped] config) -> void
6
+ def build: () -> Ledger
7
+
8
+ private
9
+
10
+ def stringify_keys: (untyped value) -> untyped
11
+ def required_value: (Hash[String, untyped] config, String key) -> untyped
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ module Whodunit
2
+ module Chronicles
3
+ module Ledgers
4
+ class FileLedger < Ledger
5
+ attr_reader path: String
6
+
7
+ def initialize: (path: String) -> void
8
+ def prepare!: () -> FileLedger
9
+ def append: (LedgerEntry entry) -> LedgerEntry
10
+ def entries: () -> Array[Hash[untyped, untyped]]
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,12 @@
1
+ module Whodunit
2
+ module Chronicles
3
+ module Ledgers
4
+ class MemoryLedger < Ledger
5
+ attr_reader entries: Array[LedgerEntry]
6
+
7
+ def initialize: () -> void
8
+ def append: (LedgerEntry entry) -> LedgerEntry
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,30 @@
1
+ module Whodunit
2
+ module Chronicles
3
+ module Ledgers
4
+ class SQLiteLedger < Ledger
5
+ DEFAULT_TABLE: String
6
+
7
+ attr_reader path: String
8
+ attr_reader table_name: String
9
+
10
+ def initialize: (path: String, ?table_name: String, ?connection: untyped) -> void
11
+ def prepare!: () -> SQLiteLedger
12
+ def ensure_indexes!: () -> SQLiteLedger
13
+ def append: (LedgerEntry entry) -> LedgerEntry
14
+ def status: () -> Hash[Symbol, untyped]
15
+
16
+ private
17
+
18
+ def connection: () -> untyped
19
+ def bind_values: (LedgerEntry entry) -> Array[untyped]
20
+ def serialize_time: (untyped value) -> String
21
+ def prepared?: () -> bool
22
+ def count_entries: () -> Integer
23
+ def sqlite_constraint_error?: (StandardError error) -> bool
24
+ def quote_identifier: (untyped identifier) -> String
25
+ def quoted_table_name: () -> String
26
+ def index_name: (Symbol suffix) -> String
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,5 @@
1
+ module Whodunit
2
+ module Chronicles
3
+ VERSION: String
4
+ end
5
+ end