legion-data 1.10.17 → 1.11.7

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +2 -2
  3. data/AGENTS.md +35 -2
  4. data/CHANGELOG.md +109 -0
  5. data/CLAUDE.md +1 -30
  6. data/Gemfile +3 -0
  7. data/README.md +134 -17
  8. data/legion-data.gemspec +1 -1
  9. data/lib/legion/data/archival/policy.rb +1 -16
  10. data/lib/legion/data/archival.rb +49 -50
  11. data/lib/legion/data/archiver.rb +2 -1
  12. data/lib/legion/data/audit_log_hash_chain.rb +3 -1
  13. data/lib/legion/data/audit_record.rb +25 -31
  14. data/lib/legion/data/connection.rb +13 -46
  15. data/lib/legion/data/database_lock.rb +23 -0
  16. data/lib/legion/data/event_store.rb +14 -14
  17. data/lib/legion/data/extract.rb +5 -7
  18. data/lib/legion/data/helper.rb +1 -1
  19. data/lib/legion/data/local.rb +9 -21
  20. data/lib/legion/data/migration.rb +24 -0
  21. data/lib/legion/data/migrations/137_preserve_task_archive_fields.rb +37 -0
  22. data/lib/legion/data/migrations/138_add_append_claims.rb +11 -0
  23. data/lib/legion/data/migrations/139_create_task_logs.rb +27 -0
  24. data/lib/legion/data/model.rb +2 -2
  25. data/lib/legion/data/models/append_claim.rb +10 -0
  26. data/lib/legion/data/models/audit_log.rb +34 -2
  27. data/lib/legion/data/models/data_archive.rb +10 -0
  28. data/lib/legion/data/models/governance_event.rb +10 -0
  29. data/lib/legion/data/models/task.rb +5 -5
  30. data/lib/legion/data/models/task_log.rb +16 -0
  31. data/lib/legion/data/retention.rb +42 -11
  32. data/lib/legion/data/settings.rb +6 -9
  33. data/lib/legion/data/spool.rb +36 -16
  34. data/lib/legion/data/storage_tiers.rb +26 -20
  35. data/lib/legion/data/task/status.rb +36 -0
  36. data/lib/legion/data/version.rb +1 -1
  37. data/lib/legion/data.rb +47 -23
  38. metadata +11 -3
@@ -10,11 +10,16 @@ module Legion
10
10
  tasks: :tasks_archive,
11
11
  metering_records: :metering_records_archive
12
12
  }.freeze
13
+ COLUMN_MAP = { id: :original_id, created_at: :original_created_at, updated_at: :original_updated_at }.freeze
14
+ TASK_COLUMN_MAP = {
15
+ id: :original_id, created: :original_created_at, updated: :original_updated_at,
16
+ function_args: :args, results: :result
17
+ }.freeze
13
18
 
14
19
  class << self
15
20
  include Legion::Logging::Helper
16
21
 
17
- def archive!(policy: Policy.new, dry_run: false)
22
+ def archive!(policy: Policy.from_settings, dry_run: false)
18
23
  log.info "Archival run started dry_run=#{dry_run} tables=#{policy.tables.size}"
19
24
  results = {}
20
25
  policy.tables.each do |table_name|
@@ -42,17 +47,17 @@ module Legion
42
47
  return 0 unless archive_table && db_ready?(archive_table)
43
48
 
44
49
  conn = Legion::Data.connection
45
- restored = 0
46
- conn.transaction do
47
- conn[archive_table].where(original_id: ids).each do |row|
48
- restore_row = row.except(:id, :archived_at, :original_id, :original_created_at, :original_updated_at)
49
- restore_row[:id] = row[:original_id]
50
- restore_row[:created_at] = row[:original_created_at]
51
- restore_row[:updated_at] = row[:original_updated_at]
52
- conn[source_table].insert(restore_row)
53
- restored += 1
50
+ restored = conn.transaction(savepoint: true, **(conn.database_type == :sqlite ? { mode: :immediate } : {})) do
51
+ dataset = conn[archive_table].where(original_id: ids).order(:original_id)
52
+ dataset = dataset.for_update unless conn.database_type == :sqlite
53
+ archived = dataset.all
54
+ rows = restore_row_values(conn: conn, source: source_table, archived: archived)
55
+ rows.each do |row|
56
+ conn[source_table].insert(source_table == :tasks ? row.merge(parent_id: nil, master_id: nil) : row)
54
57
  end
55
- conn[archive_table].where(original_id: ids).delete
58
+ rows.each { |row| conn[:tasks].where(id: row[:id]).update(row.slice(:parent_id, :master_id)) } if source_table == :tasks
59
+ conn[archive_table].where(id: archived.map { |row| row[:id] }).delete
60
+ rows.size
56
61
  end
57
62
  log.info "Restored #{restored} row(s) from #{archive_table} -> #{source_table}"
58
63
  restored
@@ -82,30 +87,8 @@ module Legion
82
87
 
83
88
  return { archived: 0, cutoff: cutoff.iso8601 } unless conn&.table_exists?(:tasks) && conn.table_exists?(:tasks_archive)
84
89
 
85
- candidates = conn[:tasks]
86
- .where(status: %w[completed failed])
87
- .where(Sequel.lit('created < ?', cutoff))
88
- .limit(batch_size)
89
-
90
- count = candidates.count
91
- if count.positive?
92
- archive_cols = conn.schema(:tasks_archive).to_set(&:first)
93
- conn.transaction do
94
- candidates.each do |row|
95
- archive_row = {
96
- original_id: row[:id],
97
- status: row[:status],
98
- relationship_id: row[:relationship_id],
99
- original_created_at: row[:created],
100
- original_updated_at: row[:updated],
101
- archived_at: Time.now
102
- }
103
- archive_row[:archive_reason] = 'completed_task_archival' if archive_cols.include?(:archive_reason)
104
- conn[:tasks_archive].insert(archive_row)
105
- end
106
- conn[:tasks].where(id: candidates.select(:id)).delete
107
- end
108
- end
90
+ count = archive_table!(source: :tasks, destination: :tasks_archive, cutoff: cutoff,
91
+ batch_size: batch_size, dry_run: false, statuses: %w[completed failed])
109
92
 
110
93
  log.info "archive_completed_tasks: archived #{count} tasks (cutoff: #{cutoff.iso8601})"
111
94
  { archived: count, cutoff: cutoff.iso8601 }
@@ -135,24 +118,40 @@ module Legion
135
118
 
136
119
  private
137
120
 
138
- def archive_table!(source:, destination:, cutoff:, batch_size:, dry_run:)
121
+ def archive_table!(source:, destination:, cutoff:, batch_size:, dry_run:, statuses: nil)
139
122
  conn = Legion::Data.connection
140
- candidates = conn[source].where { created_at < cutoff }.limit(batch_size)
141
- count = candidates.count
142
- return count if dry_run || count.zero?
143
-
144
- conn.transaction do
145
- candidates.each do |row|
146
- archive_row = row.dup
147
- archive_row[:original_id] = archive_row.delete(:id)
148
- archive_row[:original_created_at] = archive_row.delete(:created_at)
149
- archive_row[:original_updated_at] = archive_row.delete(:updated_at)
150
- archive_row[:archived_at] = Time.now
151
- conn[destination].insert(archive_row)
123
+ date_column = Policy::DATE_COLUMN_OVERRIDES.fetch(source.to_s, :created_at)
124
+ candidates = conn[source].where(Sequel[date_column] < cutoff).order(:id).limit(batch_size)
125
+ candidates = candidates.where(status: statuses) if statuses
126
+ if source == :tasks
127
+ references = conn.from(Sequel.as(:tasks, :dependent_tasks))
128
+ .where(Sequel.|({ parent_id: Sequel[:tasks][:id] }, { master_id: Sequel[:tasks][:id] }))
129
+ .exclude(id: Sequel[:tasks][:id]).exists
130
+ candidates = candidates.exclude(references)
131
+ end
132
+ return candidates.count if dry_run
133
+
134
+ mapping = source == :tasks ? TASK_COLUMN_MAP : COLUMN_MAP
135
+ conn.transaction(savepoint: true, **(conn.database_type == :sqlite ? { mode: :immediate } : {})) do
136
+ locked = conn.database_type == :sqlite ? candidates : candidates.for_update
137
+ selected_ids = locked.select_map(:id)
138
+ selected = conn[source].where(id: selected_ids).order(:id)
139
+ selected = selected.exclude(references) if source == :tasks
140
+ selected = selected.for_update unless conn.database_type == :sqlite
141
+ rows = selected.all
142
+ rows.each do |row|
143
+ archive_row = row.to_h { |column, value| [mapping.fetch(column, column), value] }
144
+ conn[destination].insert(archive_row.merge(archived_at: Time.now))
152
145
  end
153
- conn[source].where(id: candidates.select(:id)).delete
146
+ conn[source].where(id: rows.map { |row| row[:id] }).delete
147
+ rows.size
154
148
  end
155
- count
149
+ end
150
+
151
+ def restore_row_values(conn:, source:, archived:)
152
+ mapping = source == :tasks ? TASK_COLUMN_MAP : COLUMN_MAP
153
+ columns = conn.schema(source).reject { |_, info| info[:generated] }.map(&:first)
154
+ archived.map { |row| columns.to_h { |column| [column, row[mapping.fetch(column, column)]] } }
156
155
  end
157
156
 
158
157
  def db_ready?(table)
@@ -115,7 +115,8 @@ module Legion
115
115
  end
116
116
 
117
117
  def archive_batch(conn:, table:, cutoff:, batch_size:, now:, storage_backend:)
118
- rows = conn[table].where { created_at < cutoff }.limit(batch_size).all
118
+ candidates = conn[table].where { created_at < cutoff }.limit(batch_size)
119
+ rows = candidates.all
119
120
  return if rows.empty?
120
121
 
121
122
  batch_id = SecureRandom.uuid
@@ -19,6 +19,7 @@ module Legion
19
19
 
20
20
  def verify(records)
21
21
  previous_hash = GENESIS_HASH
22
+ length = 0
22
23
  records.each do |record|
23
24
  return invalid(record, :parent_mismatch) unless value_for(record, :previous_hash).to_s == previous_hash
24
25
 
@@ -26,9 +27,10 @@ module Legion
26
27
  return invalid(record, :hash_mismatch) unless value_for(record, :record_hash).to_s == expected
27
28
 
28
29
  previous_hash = expected
30
+ length += 1
29
31
  end
30
32
 
31
- { valid: true, length: records.size }
33
+ { valid: true, length: length }
32
34
  end
33
35
 
34
36
  def canonical_payload(record)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'legion/logging/helper'
4
4
  require 'digest'
5
+ require 'legion/data/database_lock'
5
6
 
6
7
  module Legion
7
8
  module Data
@@ -20,17 +21,16 @@ module Legion
20
21
  # @param metadata [Hash] optional structured context (serialised to JSON)
21
22
  # @param sign [Boolean] when true, attempt signing via legion-crypt
22
23
  def append(chain_id:, content_type:, content_hash:, metadata: {}, sign: false)
23
- return { error: 'db unavailable' } unless db_ready?
24
+ return { error: 'db unavailable' } unless Legion::Data.connected?
24
25
 
25
- conn = Legion::Data.connection
26
- conn.transaction do
27
- parent_hash = latest_chain_hash(conn, chain_id)
26
+ Legion::Data::DatabaseLock.with_claim(key: { kind: 'audit', stream_id: chain_id }) do
27
+ parent_hash = latest_chain_hash(chain_id: chain_id)
28
28
  ts = truncate_to_us(Time.now)
29
29
  ch = compute_chain_hash(parent_hash, content_hash, ts, content_type)
30
30
  sig = sign ? sign_record(ch) : nil
31
31
  meta_json = metadata.empty? ? nil : Legion::JSON.dump(metadata)
32
32
 
33
- id = conn[:audit_records].insert(
33
+ record = Legion::Data::Model::AuditRecord.create(
34
34
  chain_id: chain_id,
35
35
  content_type: content_type,
36
36
  content_hash: content_hash,
@@ -41,23 +41,23 @@ module Legion
41
41
  created_at: ts
42
42
  )
43
43
 
44
- log.debug "AuditRecord append: chain=#{chain_id} type=#{content_type} id=#{id}"
45
- { id: id, chain_id: chain_id, chain_hash: ch, parent_hash: parent_hash }
44
+ log.debug "AuditRecord append: chain=#{chain_id} type=#{content_type} id=#{record.id}"
45
+ { id: record.id, chain_id: chain_id, chain_hash: ch, parent_hash: parent_hash }
46
46
  end
47
47
  end
48
48
 
49
- # Walk all records in the chain ordered by creation time and verify that
49
+ # Walk all records in append order and verify that
50
50
  # each record's stored chain_hash matches a freshly computed one.
51
51
  #
52
52
  # @param chain_id [String]
53
53
  # @return [Hash] { valid: Boolean, length: Integer, broken_at: Integer? }
54
54
  def verify(chain_id:)
55
- return { valid: false, error: 'db unavailable' } unless db_ready?
55
+ return { valid: false, error: 'db unavailable' } unless Legion::Data.connected?
56
56
 
57
- records = Legion::Data.connection[:audit_records]
58
- .where(chain_id: chain_id)
59
- .order(:created_at, :id)
60
- .all
57
+ records = Legion::Data::Model::AuditRecord
58
+ .where(chain_id: chain_id)
59
+ .order(:id)
60
+ .all
61
61
 
62
62
  prev_hash = GENESIS_HASH
63
63
  records.each do |r|
@@ -84,11 +84,11 @@ module Legion
84
84
  # @param since [Time, nil] optional lower bound on created_at
85
85
  # @param limit [Integer]
86
86
  def walk(chain_id:, since: nil, limit: 1000)
87
- return [] unless db_ready?
87
+ return [] unless Legion::Data.connected?
88
88
 
89
- ds = Legion::Data.connection[:audit_records].where(chain_id: chain_id)
89
+ ds = Legion::Data::Model::AuditRecord.where(chain_id: chain_id)
90
90
  ds = ds.where { created_at >= since } if since
91
- ds.order(:created_at, :id).limit(limit).all.map { |r| deserialize(r) }
91
+ ds.order(:id).limit(limit).all.map { |r| deserialize(r) }
92
92
  end
93
93
 
94
94
  # Return records filtered by content_type across all chains.
@@ -97,9 +97,9 @@ module Legion
97
97
  # @param since [Time, nil]
98
98
  # @param limit [Integer]
99
99
  def query_by_type(content_type:, since: nil, limit: 100)
100
- return [] unless db_ready?
100
+ return [] unless Legion::Data.connected?
101
101
 
102
- ds = Legion::Data.connection[:audit_records].where(content_type: content_type)
102
+ ds = Legion::Data::Model::AuditRecord.where(content_type: content_type)
103
103
  ds = ds.where { created_at >= since } if since
104
104
  ds.order(Sequel.desc(:created_at)).limit(limit).all.map { |r| deserialize(r) }
105
105
  end
@@ -135,12 +135,13 @@ module Legion
135
135
  ::Time.at(Rational(us, 1_000_000))
136
136
  end
137
137
 
138
- def latest_chain_hash(conn, chain_id)
139
- last = conn[:audit_records]
140
- .select(:chain_hash)
141
- .where(chain_id: chain_id)
142
- .order(Sequel.desc(:created_at), Sequel.desc(:id))
143
- .first
138
+ def latest_chain_hash(chain_id:)
139
+ records = Legion::Data::Model::AuditRecord
140
+ .select(:chain_hash)
141
+ .where(chain_id: chain_id)
142
+ .order(Sequel.desc(:id))
143
+ records = records.for_update unless Legion::Data::Model::AuditRecord.db.database_type == :sqlite
144
+ last = records.first
144
145
  last ? last[:chain_hash] : GENESIS_HASH
145
146
  end
146
147
 
@@ -166,13 +167,6 @@ module Legion
166
167
  created_at: row[:created_at]
167
168
  }
168
169
  end
169
-
170
- def db_ready?
171
- defined?(Legion::Data) && Legion::Data.connection&.table_exists?(:audit_records)
172
- rescue StandardError => e
173
- handle_exception(e, level: :warn, handled: true, operation: :audit_record_db_ready?)
174
- false
175
- end
176
170
  end
177
171
  end
178
172
  end
@@ -168,14 +168,14 @@ module Legion
168
168
  attr_accessor :sequel
169
169
 
170
170
  def adapter
171
- @adapter ||= Legion::Settings[:data][:adapter]&.to_sym || :sqlite
171
+ @adapter || Legion::Settings[:data][:adapter].to_sym
172
172
  end
173
173
 
174
174
  def setup
175
175
  Legion::Settings[:data][:connected] = false
176
176
  @sequel&.disconnect
177
177
  @sequel = nil
178
- @adapter = Legion::Settings[:data][:adapter]&.to_sym || :sqlite
178
+ @adapter = Legion::Settings[:data][:adapter].to_sym
179
179
  opts = sequel_opts
180
180
  log.info("Legion::Data::Connection setup adapter=#{adapter}")
181
181
  @fallback_active = false
@@ -189,7 +189,7 @@ module Legion
189
189
  rescue UnresolvedCredentialError => e
190
190
  handle_exception(e, level: :fatal, handled: false, operation: :data_connect)
191
191
  raise
192
- rescue ::Sequel::DatabaseConnectionError => e
192
+ rescue StandardError => e
193
193
  raise unless dev_fallback?
194
194
 
195
195
  log.error("Legion::Data FALLING BACK TO SQLITE — #{attempted_adapter} network DB connection failed: #{e.message}")
@@ -220,7 +220,7 @@ module Legion
220
220
  adapter: adapter,
221
221
  connected: Legion::Settings[:data][:connected],
222
222
  fallback_active: @fallback_active || false,
223
- configured_adapter: Legion::Settings[:data][:adapter]&.to_sym || :sqlite,
223
+ configured_adapter: Legion::Settings[:data][:adapter].to_sym,
224
224
  sequel_alive: (begin
225
225
  !@sequel&.test_connection.nil?
226
226
  rescue StandardError => e
@@ -287,16 +287,14 @@ module Legion
287
287
  end
288
288
 
289
289
  def shutdown
290
- if @sequel
291
- timeout = Legion::Settings[:data][:shutdown_timeout]
292
- force_disconnect_pool(timeout: timeout)
293
- end
290
+ @sequel&.disconnect
294
291
  @query_file_logger&.close
295
292
  @query_file_logger = nil
296
293
  @fallback_active = false
297
294
  @replica_servers = []
298
295
  Legion::Settings[:data][:connected] = false
299
296
  log.info 'Legion::Data connection closed'
297
+ true
300
298
  end
301
299
 
302
300
  def reconnect_with_fresh_creds
@@ -317,6 +315,7 @@ module Legion
317
315
  old_user = @sequel.opts[:user]
318
316
  @sequel.opts[:user] = new_user
319
317
  @sequel.opts[:password] = new_pass
318
+ Legion::Data.reset_privileges!
320
319
 
321
320
  @sequel.disconnect
322
321
  expire_all_pooled_connections
@@ -354,7 +353,7 @@ module Legion
354
353
  return creds if adapter == :sqlite
355
354
  return creds unless defined?(Legion::Crypt::TLS)
356
355
 
357
- tls_settings = data_tls_settings
356
+ tls_settings = Legion::Settings[:data][:tls]
358
357
  return creds unless tls_settings[:enabled] == true
359
358
 
360
359
  tls = Legion::Crypt::TLS.resolve(tls_settings, port: port)
@@ -402,15 +401,6 @@ module Legion
402
401
 
403
402
  private
404
403
 
405
- def data_tls_settings
406
- return {} unless defined?(Legion::Settings)
407
-
408
- Legion::Settings[:data][:tls] || {}
409
- rescue StandardError => e
410
- handle_exception(e, level: :warn, handled: true, operation: :data_tls_settings)
411
- {}
412
- end
413
-
414
404
  def log_connection_info
415
405
  if adapter == :sqlite
416
406
  log.info "Connected to SQLite at #{sqlite_path}"
@@ -441,30 +431,8 @@ module Legion
441
431
  data_settings[:dev_mode] == true && data_settings[:dev_fallback] != false
442
432
  end
443
433
 
444
- def force_disconnect_pool(timeout:)
445
- pool = @sequel.pool
446
- in_use = pool.size - (pool.respond_to?(:available_connections) ? pool.available_connections.size : 0)
447
-
448
- if in_use.positive?
449
- log.warn("Legion::Data shutdown: #{in_use} connection(s) still checked out, waiting up to #{timeout}s")
450
- deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout
451
- while ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) < deadline
452
- break if pool.size <= (pool.respond_to?(:available_connections) ? pool.available_connections.size : 0)
453
-
454
- sleep 0.1
455
- end
456
- remaining = pool.size - (pool.respond_to?(:available_connections) ? pool.available_connections.size : 0)
457
- log.warn("Legion::Data shutdown: force-disconnecting #{remaining} wedged connection(s)") if remaining.positive?
458
- end
459
-
460
- @sequel.disconnect
461
- rescue StandardError => e
462
- handle_exception(e, level: :warn, handled: true, operation: :force_disconnect_pool)
463
- @sequel&.disconnect rescue nil # rubocop:disable Style/RescueModifier
464
- end
465
-
466
434
  def sqlite_path
467
- path = Legion::Settings[:data][:creds][:database] || 'legionio.db'
435
+ path = Legion::Settings[:data][:creds][:database]
468
436
  return path if File.absolute_path?(path)
469
437
 
470
438
  base_dir = File.expand_path('~/.legionio/data')
@@ -610,7 +578,7 @@ module Legion
610
578
  stats[pragma.to_sym] = val unless val.nil?
611
579
  end
612
580
 
613
- db_path = Legion::Settings[:data][:creds][:database] || 'legionio.db'
581
+ db_path = Legion::Settings[:data][:creds][:database]
614
582
  stats[:file_size] = File.size(db_path) if File.exist?(db_path)
615
583
  stats[:database_size_bytes] = (stats[:page_size].to_i * stats[:page_count].to_i) if stats[:page_size] && stats[:page_count]
616
584
  stats
@@ -698,15 +666,14 @@ module Legion
698
666
 
699
667
  install_auth_failure_hook
700
668
  rescue StandardError => e
701
- handle_exception(e, level: :error, handled: false, operation: :configure_extensions, adapter: adapter)
702
- raise
669
+ handle_exception(e, level: :warn, handled: true, operation: :configure_extensions, adapter: adapter)
670
+ raise e
703
671
  end
704
672
 
705
673
  def install_auth_failure_hook
706
674
  Legion::Data::AuthFailureHandler.install(@sequel)
707
675
  rescue StandardError => e
708
- handle_exception(e, level: :error, handled: false, operation: :install_auth_failure_hook)
709
- raise
676
+ handle_exception(e, level: :warn, handled: true, operation: :install_auth_failure_hook)
710
677
  end
711
678
 
712
679
  def build_data_logger
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Data
5
+ module DatabaseLock
6
+ def self.with_claim(key:)
7
+ database = Legion::Data::Model::AppendClaim.db
8
+ options = { savepoint: true }
9
+ options[:mode] = :immediate if database.database_type == :sqlite
10
+ database.transaction(**options) do
11
+ claim = Legion::Data::Model::AppendClaim.where(key)
12
+ # InnoDB needs exclusive duplicate-key locks to avoid shared-lock upgrade deadlocks.
13
+ claim = claim.on_duplicate_key_update(*key.keys) if database.database_type == :mysql
14
+ claim.insert(key)
15
+ result = yield
16
+ # The unique-key lock lasts until commit; historical keys need no retained rows.
17
+ claim.delete
18
+ result
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'legion/logging/helper'
4
4
  require 'digest'
5
+ require 'legion/data/database_lock'
5
6
 
6
7
  module Legion
7
8
  module Data
@@ -20,12 +21,11 @@ module Legion
20
21
  def append(stream:, type:, data: {}, metadata: {})
21
22
  return { error: 'db unavailable' } unless db_ready?
22
23
 
23
- conn = Legion::Data.connection
24
- conn.transaction do
25
- last = conn[:governance_events]
26
- .where(stream_id: stream)
27
- .order(Sequel.desc(:sequence_number))
28
- .first
24
+ database = Legion::Data::Model::GovernanceEvent.db
25
+ Legion::Data::DatabaseLock.with_claim(key: { kind: 'event', stream_id: stream }) do
26
+ records = Legion::Data::Model::GovernanceEvent.where(stream_id: stream).order(Sequel.desc(:sequence_number))
27
+ records = records.for_update unless database.database_type == :sqlite
28
+ last = records.first
29
29
 
30
30
  seq = (last&.[](:sequence_number) || 0) + 1
31
31
  prev_hash = last&.[](:event_hash) || ('0' * 64)
@@ -34,7 +34,7 @@ module Legion
34
34
  metadata_json = Legion::JSON.dump(metadata)
35
35
  event_hash = compute_hash(stream, seq, type, data_json, metadata_json, prev_hash)
36
36
 
37
- conn[:governance_events].insert(
37
+ Legion::Data::Model::GovernanceEvent.create(
38
38
  stream_id: stream,
39
39
  event_type: type,
40
40
  sequence_number: seq,
@@ -53,7 +53,7 @@ module Legion
53
53
  def read_stream(stream, since: nil)
54
54
  return [] unless db_ready?
55
55
 
56
- ds = Legion::Data.connection[:governance_events].where(stream_id: stream)
56
+ ds = Legion::Data::Model::GovernanceEvent.where(stream_id: stream)
57
57
  ds = ds.where { created_at >= since } if since
58
58
  ds.order(:sequence_number).all.map { |e| deserialize(e) }
59
59
  end
@@ -61,7 +61,7 @@ module Legion
61
61
  def read_by_type(type, since: nil, limit: 100)
62
62
  return [] unless db_ready?
63
63
 
64
- ds = Legion::Data.connection[:governance_events].where(event_type: type)
64
+ ds = Legion::Data::Model::GovernanceEvent.where(event_type: type)
65
65
  ds = ds.where { created_at >= since } if since
66
66
  ds.order(Sequel.desc(:created_at)).limit(limit).all.map { |e| deserialize(e) }
67
67
  end
@@ -69,10 +69,10 @@ module Legion
69
69
  def verify_chain(stream)
70
70
  return { valid: false, error: 'db unavailable' } unless db_ready?
71
71
 
72
- events = Legion::Data.connection[:governance_events]
73
- .where(stream_id: stream)
74
- .order(:sequence_number)
75
- .all
72
+ events = Legion::Data::Model::GovernanceEvent
73
+ .where(stream_id: stream)
74
+ .order(:sequence_number)
75
+ .all
76
76
 
77
77
  prev_hash = '0' * 64
78
78
  legacy_hashes = 0
@@ -128,7 +128,7 @@ module Legion
128
128
  end
129
129
 
130
130
  def db_ready?
131
- defined?(Legion::Data) && Legion::Data.connection&.table_exists?(:governance_events)
131
+ Legion::Data.connected? && Legion::Data::Model::GovernanceEvent.db.table_exists?(:governance_events)
132
132
  rescue StandardError => e
133
133
  handle_exception(e, level: :warn, handled: true, operation: :event_store_db_ready?)
134
134
  false
@@ -102,16 +102,14 @@ module Legion
102
102
  end
103
103
 
104
104
  def persist_step_timings(extract_id, timings)
105
- return unless defined?(Legion::Data)
105
+ return unless Legion::Data.connected?
106
106
 
107
- connection = Legion::Data.connection
108
- return unless connection&.table_exists?(:extract_step_timings)
107
+ existing_steps = Legion::Data::Model::ExtractStepTiming.where(extract_id: extract_id).select_map(:name)
108
+ timings.each do |timing|
109
+ next if existing_steps.include?(timing[:name])
109
110
 
110
- existing_steps = connection[:extract_step_timings].where(extract_id: extract_id).select_map(:name)
111
- rows = timings.reject { |timing| existing_steps.include?(timing[:name]) }.map do |timing|
112
- timing.merge(extract_id: extract_id)
111
+ Legion::Data::Model::ExtractStepTiming.create(timing.merge(extract_id: extract_id))
113
112
  end
114
- connection[:extract_step_timings].multi_insert(rows) unless rows.empty?
115
113
  rescue StandardError => e
116
114
  handle_exception(e, level: :warn, handled: true, operation: :persist_extract_step_timings,
117
115
  extract_id: extract_id)
@@ -20,7 +20,7 @@ module Legion
20
20
  end
21
21
 
22
22
  def data_connected?
23
- defined?(Legion::Settings) && Legion::Settings[:data][:connected]
23
+ Legion::Settings[:data][:connected]
24
24
  end
25
25
 
26
26
  def data_connection
@@ -17,7 +17,7 @@ module Legion
17
17
  def setup(database: nil, **)
18
18
  return if @connected
19
19
 
20
- db_file = database || local_settings[:database] || 'legionio_local.db'
20
+ db_file = database || Legion::Settings[:data][:local][:database]
21
21
  unless File.absolute_path?(db_file)
22
22
  base_dir = File.expand_path('~/.legionio')
23
23
  FileUtils.mkdir_p(base_dir)
@@ -26,19 +26,19 @@ module Legion
26
26
  @db_path = db_file
27
27
 
28
28
  sqlite_defaults = Legion::Data::Connection::ADAPTER_DEFAULTS.fetch(:sqlite, {})
29
- data = defined?(Legion::Settings) ? Legion::Settings[:data] : {}
29
+ data = Legion::Settings[:data]
30
30
  opts = { adapter: :sqlite, database: db_file }
31
31
  Legion::Data::Connection::ADAPTER_KEYS.fetch(:sqlite, []).each do |key|
32
32
  val = data.key?(key) && !data[key].nil? ? data[key] : sqlite_defaults[key]
33
33
  opts[key] = val unless val.nil?
34
34
  end
35
- opts[:timeout] = data[:timeout] || 30_000
35
+ opts[:timeout] = data[:timeout].nil? ? 30_000 : data[:timeout]
36
36
  opts[:after_connect] = proc do |connection|
37
37
  Legion::Data::Connection.configure_sqlite_busy_handler(connection: connection, timeout: opts[:timeout])
38
38
  connection.execute_batch('PRAGMA synchronous=NORMAL; PRAGMA cache_size=-20000; PRAGMA mmap_size=268435456')
39
39
  end
40
40
 
41
- if local_settings[:query_log]
41
+ if Legion::Settings[:data][:local][:query_log]
42
42
  log_path = File.join(Legion::Data::Connection::QUERY_LOG_DIR, 'data-local-query.log')
43
43
  @query_file_logger = Legion::Data::Connection::QueryFileLogger.new(log_path)
44
44
  opts[:logger] = @query_file_logger
@@ -99,7 +99,7 @@ module Legion
99
99
  connected: true,
100
100
  adapter: :sqlite,
101
101
  path: @db_path,
102
- query_log: local_settings[:query_log] || false,
102
+ query_log: Legion::Settings[:data][:local][:query_log],
103
103
  query_log_path: @query_file_logger&.path,
104
104
  registered_migrations: registered_migrations.keys
105
105
  }
@@ -135,7 +135,7 @@ module Legion
135
135
  private
136
136
 
137
137
  def run_migrations
138
- return unless local_settings.dig(:migrations, :auto_migrate) != false
138
+ return unless Legion::Settings[:data][:local][:migrations][:auto_migrate]
139
139
 
140
140
  registered_migrations.each do |name, path|
141
141
  run_single_migration(name, path)
@@ -143,7 +143,7 @@ module Legion
143
143
  end
144
144
 
145
145
  def run_single_migration(name, path)
146
- return unless local_settings.dig(:migrations, :auto_migrate) != false
146
+ return unless Legion::Settings[:data][:local][:migrations][:auto_migrate]
147
147
  return unless File.directory?(path)
148
148
 
149
149
  table = :"schema_migrations_#{name}"
@@ -153,12 +153,6 @@ module Legion
153
153
  raise
154
154
  end
155
155
 
156
- def local_settings
157
- return {} unless defined?(Legion::Settings)
158
-
159
- Legion::Settings[:data]&.dig(:local) || {}
160
- end
161
-
162
156
  def build_local_logger
163
157
  tagged = if defined?(Legion::Logging::TaggedLogger) && respond_to?(:tagged_logger_settings, true)
164
158
  Legion::Logging::TaggedLogger.new(
@@ -181,17 +175,11 @@ module Legion
181
175
  end
182
176
 
183
177
  def resolved_sql_log_level
184
- (local_settings[:sql_log_level] || Legion::Settings[:data][:sql_log_level] || 'debug').to_sym
185
- rescue StandardError => e
186
- handle_exception(e, level: :warn, handled: true, operation: :resolved_sql_log_level)
187
- :debug
178
+ Legion::Settings[:data][:local].fetch(:sql_log_level, Legion::Settings[:data][:sql_log_level]).to_sym
188
179
  end
189
180
 
190
181
  def resolved_log_warn_duration
191
- local_settings[:log_warn_duration] || Legion::Settings[:data][:log_warn_duration]
192
- rescue StandardError => e
193
- handle_exception(e, level: :warn, handled: true, operation: :resolved_log_warn_duration)
194
- nil
182
+ Legion::Settings[:data][:local].fetch(:log_warn_duration, Legion::Settings[:data][:log_warn_duration])
195
183
  end
196
184
  end
197
185
  end