whodunit-chronicles 0.1.0 → 0.3.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.
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Whodunit
6
+ module Chronicles
7
+ # Handles database connections for chronicles processing
8
+ #
9
+ # Provides adapter-agnostic connection management for both PostgreSQL and MySQL
10
+ module Connection
11
+ private
12
+
13
+ def create_connection
14
+ audit_url = @audit_database_url || Chronicles.config.database_url
15
+
16
+ case detect_database_type(audit_url)
17
+ when :postgresql
18
+ require 'pg'
19
+ PG.connect(audit_url)
20
+ when :mysql
21
+ require 'trilogy'
22
+ parsed = parse_mysql_url(audit_url)
23
+ Trilogy.new(
24
+ host: parsed[:host],
25
+ port: parsed[:port] || 3306,
26
+ username: parsed[:username],
27
+ password: parsed[:password],
28
+ database: parsed[:database],
29
+ ssl: parsed[:ssl],
30
+ )
31
+ else
32
+ raise ConfigurationError, 'Unsupported database type for connection'
33
+ end
34
+ end
35
+
36
+ def detect_database_type(url)
37
+ return Chronicles.config.adapter unless url
38
+ return :postgresql if url.start_with?('postgres://', 'postgresql://')
39
+ return :mysql if url.start_with?('mysql://', 'mysql2://')
40
+
41
+ # Fallback to configured adapter
42
+ Chronicles.config.adapter
43
+ end
44
+
45
+ def parse_mysql_url(url)
46
+ return {} if url.nil? || url.empty?
47
+
48
+ uri = URI.parse(url)
49
+ {
50
+ host: uri.host,
51
+ port: uri.port,
52
+ username: uri.user,
53
+ password: uri.password,
54
+ database: uri.path&.sub('/', ''),
55
+ ssl: uri.query&.include?('ssl=true'),
56
+ }
57
+ end
58
+
59
+ def connection_active?
60
+ case detect_database_type(@audit_database_url || Chronicles.config.database_url)
61
+ when :postgresql
62
+ @connection && !@connection.finished?
63
+ when :mysql
64
+ @connection&.ping
65
+ else
66
+ false
67
+ end
68
+ end
69
+
70
+ def setup_connection_specifics
71
+ case detect_database_type(@audit_database_url || Chronicles.config.database_url)
72
+ when :postgresql
73
+ @connection.type_map_for_results = PG::BasicTypeMapForResults.new(@connection)
74
+ when :mysql
75
+ # MySQL/Trilogy doesn't need special setup
76
+ end
77
+ end
78
+
79
+ def ensure_connection
80
+ return if @connection && connection_active?
81
+
82
+ @connection = create_connection
83
+ setup_connection_specifics
84
+ ensure_table_exists
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whodunit
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
14
+ class Error < StandardError; end
15
+
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
33
+
34
+ # Raised when processing a change event fails.
35
+ class ProcessingError < Error; end
36
+
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
42
+ end
43
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whodunit
4
+ module Chronicles
5
+ # Handles record persistence for different database adapters
6
+ #
7
+ # Provides adapter-specific SQL for inserting chronicle records
8
+ module Persistence
9
+ private
10
+
11
+ def persist_record(record)
12
+ db_type = detect_database_type(@audit_database_url || Chronicles.config.database_url)
13
+
14
+ case db_type
15
+ when :postgresql
16
+ persist_record_postgresql(record)
17
+ when :mysql
18
+ persist_record_mysql(record)
19
+ end
20
+ end
21
+
22
+ def persist_record_postgresql(record)
23
+ sql = <<~SQL
24
+ INSERT INTO whodunit_chronicles_audits (
25
+ table_name, schema_name, record_id, action, old_data, new_data, changes,
26
+ user_id, user_type, transaction_id, sequence_number, occurred_at, created_at, metadata
27
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
28
+ RETURNING id
29
+ SQL
30
+
31
+ params = build_record_params(record)
32
+ result = @connection.exec_params(sql, params)
33
+ record[:id] = result.first['id'].to_i
34
+ result.clear
35
+
36
+ record
37
+ end
38
+
39
+ def persist_record_mysql(record)
40
+ sql = <<~SQL
41
+ INSERT INTO whodunit_chronicles_audits (
42
+ table_name, schema_name, record_id, action, old_data, new_data, changes,
43
+ user_id, user_type, transaction_id, sequence_number, occurred_at, created_at, metadata
44
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
45
+ SQL
46
+
47
+ params = build_record_params(record)
48
+ @connection.execute(sql, *params)
49
+ record[:id] = @connection.last_insert_id
50
+
51
+ record
52
+ end
53
+
54
+ def persist_records_batch(records)
55
+ return records if records.empty?
56
+
57
+ db_type = detect_database_type(@audit_database_url || Chronicles.config.database_url)
58
+
59
+ case db_type
60
+ when :postgresql
61
+ persist_records_batch_postgresql(records)
62
+ when :mysql
63
+ persist_records_batch_mysql(records)
64
+ end
65
+ end
66
+
67
+ def persist_records_batch_postgresql(records)
68
+ # Use multi-row INSERT for better performance
69
+ values_clauses = []
70
+ all_params = []
71
+ param_index = 1
72
+
73
+ records.each do |record|
74
+ param_positions = (param_index..(param_index + 13)).map { |i| "$#{i}" }.join(', ')
75
+ values_clauses << "(#{param_positions})"
76
+ all_params.concat(build_record_params(record))
77
+ param_index += 14
78
+ end
79
+
80
+ sql = <<~SQL
81
+ INSERT INTO whodunit_chronicles_audits (
82
+ table_name, schema_name, record_id, action, old_data, new_data, changes,
83
+ user_id, user_type, transaction_id, sequence_number, occurred_at, created_at, metadata
84
+ ) VALUES #{values_clauses.join(', ')}
85
+ RETURNING id
86
+ SQL
87
+
88
+ result = @connection.exec_params(sql, all_params)
89
+
90
+ # Set IDs on the records
91
+ result.each_with_index do |row, index|
92
+ records[index][:id] = row['id'].to_i
93
+ end
94
+
95
+ result.clear
96
+ records
97
+ end
98
+
99
+ def persist_records_batch_mysql(records)
100
+ # For MySQL, we'll use individual inserts in a transaction for simplicity
101
+ # A more optimized version could use VALUES() with multiple rows
102
+ records.each do |record|
103
+ persist_record_mysql(record)
104
+ end
105
+
106
+ records
107
+ end
108
+
109
+ def build_record_params(record)
110
+ [
111
+ record[:table_name],
112
+ record[:schema_name],
113
+ record[:record_id].to_json,
114
+ record[:action],
115
+ record[:old_data]&.to_json,
116
+ record[:new_data]&.to_json,
117
+ record[:changes].to_json,
118
+ record[:user_id],
119
+ record[:user_type],
120
+ record[:transaction_id],
121
+ record[:sequence_number],
122
+ record[:occurred_at],
123
+ record[:created_at],
124
+ record[:metadata].to_json,
125
+ ]
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whodunit
4
+ module Chronicles
5
+ # Processes database change events and creates chronicle records
6
+ #
7
+ # Transforms ChangeEvent objects into structured chronicle records
8
+ # with complete object serialization and metadata.
9
+ class Processor
10
+ include Connection
11
+ include Table
12
+ include Persistence
13
+
14
+ attr_reader :logger, :connection
15
+
16
+ def initialize(
17
+ audit_database_url: Chronicles.config.audit_database_url,
18
+ logger: Chronicles.logger
19
+ )
20
+ @audit_database_url = audit_database_url
21
+ @logger = logger
22
+ @connection = nil
23
+ end
24
+
25
+ # Process a change event and create chronicle record
26
+ #
27
+ # @param change_event [ChangeEvent] The database change to chronicle
28
+ # @return [Hash] The created chronicle record
29
+ def process(change_event)
30
+ ensure_connection
31
+
32
+ record = build_record(change_event)
33
+ persist_record(record)
34
+
35
+ log(:debug, 'Processed change event',
36
+ table: change_event.qualified_table_name,
37
+ action: change_event.action,
38
+ id: record[:id])
39
+
40
+ record
41
+ rescue StandardError => e
42
+ log(:error, 'Failed to process change event',
43
+ error: e.message,
44
+ event: change_event.to_s)
45
+ raise
46
+ end
47
+
48
+ # Process multiple change events in a batch
49
+ #
50
+ # @param change_events [Array<ChangeEvent>] Array of change events
51
+ # @return [Array<Hash>] Array of created chronicle records
52
+ def process_batch(change_events)
53
+ return [] if change_events.empty?
54
+
55
+ ensure_connection
56
+
57
+ records = change_events.map { |event| build_record(event) }
58
+ persist_records_batch(records)
59
+
60
+ log(:info, 'Processed batch of change events', count: change_events.size)
61
+
62
+ records
63
+ rescue StandardError => e
64
+ log(:error, 'Failed to process batch',
65
+ error: e.message,
66
+ count: change_events.size)
67
+ raise
68
+ end
69
+
70
+ # Close database connection
71
+ def close
72
+ @connection&.close
73
+ @connection = nil
74
+ end
75
+
76
+ private
77
+
78
+ def build_record(change_event)
79
+ user_info = extract_user_info(change_event)
80
+
81
+ {
82
+ id: nil, # Will be set by database
83
+ table_name: change_event.table_name,
84
+ schema_name: change_event.schema_name,
85
+ record_id: change_event.primary_key,
86
+ action: change_event.action,
87
+ old_data: change_event.old_data,
88
+ new_data: change_event.new_data,
89
+ changes: change_event.changes,
90
+ user_id: user_info[:user_id],
91
+ user_type: user_info[:user_type],
92
+ transaction_id: change_event.transaction_id,
93
+ sequence_number: change_event.sequence_number,
94
+ occurred_at: change_event.timestamp,
95
+ created_at: Time.now,
96
+ metadata: build_metadata(change_event),
97
+ }
98
+ end
99
+
100
+ def extract_user_info(change_event)
101
+ data = change_event.current_data || {}
102
+
103
+ # Look for Whodunit user attribution fields
104
+ user_id = data['creator_id'] || data['updater_id'] || data['deleter_id']
105
+
106
+ {
107
+ user_id: user_id,
108
+ user_type: user_id ? 'User' : nil,
109
+ }
110
+ end
111
+
112
+ def build_metadata(change_event)
113
+ {
114
+ table_schema: change_event.schema_name,
115
+ qualified_table_name: change_event.qualified_table_name,
116
+ changed_columns: change_event.changed_columns,
117
+ adapter_metadata: change_event.metadata,
118
+ chronicles_version: Chronicles::VERSION,
119
+ }
120
+ end
121
+
122
+ def log(level, message, context = {})
123
+ logger.public_send(level, message, processor: 'Processor', **context)
124
+ end
125
+ end
126
+ end
127
+ end
@@ -4,10 +4,10 @@ require 'concurrent-ruby'
4
4
 
5
5
  module Whodunit
6
6
  module Chronicles
7
- # Main service orchestrator for audit streaming
7
+ # Main service orchestrator for chronicle streaming
8
8
  #
9
- # Coordinates the stream adapter and audit processor to provide
10
- # a complete audit streaming solution with error handling and monitoring.
9
+ # Coordinates the stream adapter and processor to provide
10
+ # a complete chronicle streaming solution with error handling and monitoring.
11
11
  class Service
12
12
  attr_reader :adapter, :processor, :logger, :executor
13
13
 
@@ -17,7 +17,7 @@ module Whodunit
17
17
  logger: Chronicles.logger
18
18
  )
19
19
  @adapter = adapter || build_adapter
20
- @processor = processor || AuditProcessor.new(logger: logger)
20
+ @processor = processor || Processor.new(logger: logger)
21
21
  @logger = logger
22
22
  @executor = Concurrent::ThreadPoolExecutor.new(
23
23
  min_threads: 1,
@@ -29,13 +29,13 @@ module Whodunit
29
29
  @retry_count = 0
30
30
  end
31
31
 
32
- # Start the audit streaming service
32
+ # Start the chronicle streaming service
33
33
  #
34
34
  # @return [self]
35
35
  def start
36
36
  return self if running?
37
37
 
38
- log(:info, 'Starting Chronicles audit streaming service')
38
+ log(:info, 'Starting Chronicles streaming service')
39
39
 
40
40
  validate_setup!
41
41
  test_connections!
@@ -45,7 +45,7 @@ module Whodunit
45
45
 
46
46
  start_streaming_with_retry
47
47
 
48
- log(:info, 'Chronicles audit streaming service started successfully')
48
+ log(:info, 'Chronicles streaming service started successfully')
49
49
  self
50
50
  rescue StandardError => e
51
51
  log(:error, 'Failed to start service', error: e.message)
@@ -53,13 +53,13 @@ module Whodunit
53
53
  raise
54
54
  end
55
55
 
56
- # Stop the audit streaming service
56
+ # Stop the chronicle streaming service
57
57
  #
58
58
  # @return [void]
59
59
  def stop
60
60
  return unless running?
61
61
 
62
- log(:info, 'Stopping Chronicles audit streaming service')
62
+ log(:info, 'Stopping Chronicles streaming service')
63
63
  @running = false
64
64
 
65
65
  adapter.stop_streaming if adapter.streaming?
@@ -67,7 +67,7 @@ module Whodunit
67
67
  @executor.wait_for_termination(timeout: 30)
68
68
 
69
69
  processor.close
70
- log(:info, 'Chronicles audit streaming service stopped')
70
+ log(:info, 'Chronicles streaming service stopped')
71
71
  end
72
72
 
73
73
  # Check if service is running
@@ -94,23 +94,23 @@ module Whodunit
94
94
  }
95
95
  end
96
96
 
97
- # Set up the audit streaming infrastructure
97
+ # Set up the chronicle streaming infrastructure
98
98
  #
99
99
  # @return [void]
100
100
  def setup!
101
- log(:info, 'Setting up audit streaming infrastructure')
101
+ log(:info, 'Setting up chronicle streaming infrastructure')
102
102
  adapter.setup
103
- log(:info, 'Audit streaming infrastructure setup completed')
103
+ log(:info, 'Chronicle streaming infrastructure setup completed')
104
104
  end
105
105
 
106
- # Tear down the audit streaming infrastructure
106
+ # Tear down the chronicle streaming infrastructure
107
107
  #
108
108
  # @return [void]
109
109
  def teardown!
110
- log(:info, 'Tearing down audit streaming infrastructure')
110
+ log(:info, 'Tearing down chronicle streaming infrastructure')
111
111
  stop if running?
112
112
  adapter.teardown
113
- log(:info, 'Audit streaming infrastructure teardown completed')
113
+ log(:info, 'Chronicle streaming infrastructure teardown completed')
114
114
  end
115
115
 
116
116
  private
@@ -118,7 +118,9 @@ module Whodunit
118
118
  def build_adapter
119
119
  case Chronicles.config.adapter
120
120
  when :postgresql
121
- Adapters::PostgreSQL.new(logger: logger)
121
+ Whodunit::Chronicles::AdapterLoader.load(:postgresql, logger:)
122
+ when :mysql
123
+ Whodunit::Chronicles::AdapterLoader.load(:mysql, logger:)
122
124
  else
123
125
  raise ConfigurationError, "Unsupported adapter: #{Chronicles.config.adapter}"
124
126
  end
@@ -129,15 +131,15 @@ module Whodunit
129
131
 
130
132
  return if adapter.test_connection
131
133
 
132
- raise AdapterError, 'Failed to connect to source database'
134
+ raise AdapterLoadError, 'Failed to connect to source database'
133
135
  end
134
136
 
135
137
  def test_connections!
136
138
  adapter.test_connection
137
- # Test audit processor connection by creating a dummy connection
138
- processor.send(:ensure_audit_connection)
139
+ # Test processor connection by creating a dummy connection
140
+ processor.send(:ensure_connection)
139
141
  rescue StandardError => e
140
- raise AdapterError, "Connection test failed: #{e.message}"
142
+ raise AdapterLoadError, "Connection test failed: #{e.message}"
141
143
  end
142
144
 
143
145
  def start_streaming_with_retry
@@ -159,7 +161,7 @@ module Whodunit
159
161
 
160
162
  def process_change_event(change_event)
161
163
  return unless change_event
162
- return unless should_audit_table?(change_event)
164
+ return unless should_chronicle_table?(change_event)
163
165
 
164
166
  log(:debug, 'Processing change event',
165
167
  table: change_event.qualified_table_name,
@@ -174,8 +176,8 @@ module Whodunit
174
176
  )
175
177
  end
176
178
 
177
- def should_audit_table?(change_event)
178
- Chronicles.config.audit_table?(
179
+ def should_chronicle_table?(change_event)
180
+ Chronicles.config.chronicle_table?(
179
181
  change_event.table_name,
180
182
  change_event.schema_name,
181
183
  )
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whodunit
4
+ module Chronicles
5
+ # Handles table creation for different database adapters
6
+ #
7
+ # Provides adapter-specific SQL for creating chronicles tables
8
+ module Table
9
+ private
10
+
11
+ def ensure_table_exists
12
+ db_type = detect_database_type(@audit_database_url || Chronicles.config.database_url)
13
+
14
+ case db_type
15
+ when :postgresql
16
+ create_postgresql_table
17
+ when :mysql
18
+ create_mysql_table
19
+ end
20
+ end
21
+
22
+ def create_postgresql_table
23
+ create_sql = <<~SQL
24
+ CREATE TABLE IF NOT EXISTS whodunit_chronicles_audits (
25
+ id BIGSERIAL PRIMARY KEY,
26
+ table_name TEXT NOT NULL,
27
+ schema_name TEXT NOT NULL DEFAULT 'public',
28
+ record_id JSONB,
29
+ action TEXT NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
30
+ old_data JSONB,
31
+ new_data JSONB,
32
+ changes JSONB,
33
+ user_id BIGINT,
34
+ user_type TEXT,
35
+ transaction_id TEXT,
36
+ sequence_number INTEGER,
37
+ occurred_at TIMESTAMP WITH TIME ZONE NOT NULL,
38
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
39
+ metadata JSONB DEFAULT '{}'::jsonb,
40
+ #{' '}
41
+ -- Indexes for performance
42
+ CONSTRAINT valid_data_for_action CHECK (
43
+ (action = 'INSERT' AND old_data IS NULL AND new_data IS NOT NULL) OR
44
+ (action = 'UPDATE' AND old_data IS NOT NULL AND new_data IS NOT NULL) OR#{' '}
45
+ (action = 'DELETE' AND old_data IS NOT NULL AND new_data IS NULL)
46
+ )
47
+ );
48
+
49
+ -- Performance indexes
50
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_table_record#{' '}
51
+ ON whodunit_chronicles_audits (table_name, (record_id->>'id'));
52
+
53
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_occurred_at#{' '}
54
+ ON whodunit_chronicles_audits (occurred_at DESC);
55
+
56
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_user#{' '}
57
+ ON whodunit_chronicles_audits (user_id, user_type);
58
+
59
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_action#{' '}
60
+ ON whodunit_chronicles_audits (action);
61
+
62
+ -- GIN index for JSONB columns
63
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_record_id_gin#{' '}
64
+ ON whodunit_chronicles_audits USING GIN (record_id);
65
+
66
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_changes_gin#{' '}
67
+ ON whodunit_chronicles_audits USING GIN (changes);
68
+ SQL
69
+
70
+ @connection.exec(create_sql)
71
+ rescue PG::Error => e
72
+ # Ignore "already exists" errors from CONCURRENTLY
73
+ raise unless e.message.include?('already exists')
74
+ end
75
+
76
+ def create_mysql_table
77
+ create_sql = <<~SQL
78
+ CREATE TABLE IF NOT EXISTS whodunit_chronicles_audits (
79
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
80
+ table_name TEXT NOT NULL,
81
+ schema_name TEXT NOT NULL DEFAULT 'public',
82
+ record_id JSON,
83
+ action TEXT NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
84
+ old_data JSON,
85
+ new_data JSON,
86
+ changes JSON,
87
+ user_id BIGINT,
88
+ user_type TEXT,
89
+ transaction_id TEXT,
90
+ sequence_number INTEGER,
91
+ occurred_at TIMESTAMP NOT NULL,
92
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
93
+ metadata JSON DEFAULT (JSON_OBJECT()),
94
+ #{' '}
95
+ -- Constraint for data integrity
96
+ CONSTRAINT valid_data_for_action CHECK (
97
+ (action = 'INSERT' AND old_data IS NULL AND new_data IS NOT NULL) OR
98
+ (action = 'UPDATE' AND old_data IS NOT NULL AND new_data IS NOT NULL) OR
99
+ (action = 'DELETE' AND old_data IS NOT NULL AND new_data IS NULL)
100
+ ),
101
+ #{' '}
102
+ -- Performance indexes
103
+ INDEX idx_chronicles_audits_table_record (table_name(255), (JSON_UNQUOTE(JSON_EXTRACT(record_id, '$.id')))),
104
+ INDEX idx_chronicles_audits_occurred_at (occurred_at DESC),
105
+ INDEX idx_chronicles_audits_user (user_id, user_type(255)),
106
+ INDEX idx_chronicles_audits_action (action(50))
107
+ );
108
+ SQL
109
+
110
+ @connection.query(create_sql)
111
+ rescue StandardError => e
112
+ # Ignore "already exists" errors
113
+ unless e.message.include?('already exists') ||
114
+ (e.message.include?('Table') && e.message.include?('already exists'))
115
+ raise
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Whodunit
4
4
  module Chronicles
5
- VERSION = '0.1.0'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end