shikibu 0.1.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,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sequel'
4
+
5
+ module Shikibu
6
+ module Storage
7
+ # Handles database schema migrations from shared durax-io/schema
8
+ # Compatible with dbmate migration format and schema_migrations tracking
9
+ class Migrations
10
+ SCHEMA_PATH = File.expand_path('../../../schema/db/migrations', __dir__)
11
+
12
+ # Advisory lock key for migration (consistent across workers)
13
+ MIGRATION_LOCK_KEY = 20_251_217_000_000
14
+
15
+ class << self
16
+ # Apply migrations to the database
17
+ # @param db [Sequel::Database] The database connection
18
+ # @return [Array<String>] List of applied migration versions
19
+ def apply(db)
20
+ db_type = detect_db_type(db)
21
+ migration_dir = File.join(SCHEMA_PATH, db_type)
22
+
23
+ raise Error, "Migration directory not found: #{migration_dir}" unless File.directory?(migration_dir)
24
+
25
+ # Ensure schema_migrations table exists
26
+ ensure_schema_migrations_table(db)
27
+
28
+ # Use advisory lock to ensure only one worker applies migrations
29
+ with_migration_lock(db, db_type) do |acquired|
30
+ unless acquired
31
+ # Another worker is applying migrations, wait and return
32
+ sleep(1)
33
+ return []
34
+ end
35
+
36
+ apply_pending_migrations(db, migration_dir)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def detect_db_type(db)
43
+ case db.database_type
44
+ when :postgres then 'postgresql'
45
+ when :mysql, :mysql2 then 'mysql'
46
+ else 'sqlite'
47
+ end
48
+ end
49
+
50
+ # Create schema_migrations table if it doesn't exist (dbmate compatible)
51
+ def ensure_schema_migrations_table(db)
52
+ db.run <<~SQL
53
+ CREATE TABLE IF NOT EXISTS schema_migrations (
54
+ version VARCHAR(255) PRIMARY KEY
55
+ )
56
+ SQL
57
+ rescue Sequel::DatabaseError => e
58
+ # Handle concurrent table creation
59
+ raise unless e.message.include?('already exists') || e.message.include?('duplicate')
60
+ end
61
+
62
+ # Get set of already applied migration versions
63
+ def get_applied_migrations(db)
64
+ db[:schema_migrations].select_map(:version).to_set
65
+ rescue Sequel::DatabaseError
66
+ # Table might not exist yet
67
+ Set.new
68
+ end
69
+
70
+ # Record a migration as applied
71
+ # @return [Boolean] true if recorded, false if already exists (race condition)
72
+ def record_migration(db, version)
73
+ db[:schema_migrations].insert(version: version)
74
+ true
75
+ rescue Sequel::UniqueConstraintViolation
76
+ # Another worker already recorded this migration
77
+ false
78
+ end
79
+
80
+ # Extract version from migration filename
81
+ # e.g., "20251217000000_initial_schema.sql" -> "20251217000000"
82
+ def extract_version(filename)
83
+ basename = File.basename(filename)
84
+ match = basename.match(/^(\d+)_/)
85
+ match ? match[1] : basename.sub('.sql', '')
86
+ end
87
+
88
+ # Apply pending migrations with version tracking
89
+ def apply_pending_migrations(db, migration_dir)
90
+ applied = get_applied_migrations(db)
91
+ migration_files = Dir.glob(File.join(migration_dir, '*.sql'))
92
+ applied_versions = []
93
+
94
+ migration_files.each do |file|
95
+ version = extract_version(file)
96
+
97
+ # Skip if already applied
98
+ next if applied.include?(version)
99
+
100
+ # Apply migration
101
+ apply_migration(db, file)
102
+
103
+ # Record as applied (handles race condition)
104
+ applied_versions << version if record_migration(db, version)
105
+ end
106
+
107
+ applied_versions
108
+ end
109
+
110
+ # Acquire advisory lock for migrations (database-specific)
111
+ def with_migration_lock(db, db_type, &)
112
+ case db_type
113
+ when 'postgresql'
114
+ with_postgres_lock(db, &)
115
+ when 'mysql'
116
+ with_mysql_lock(db, &)
117
+ else
118
+ # SQLite doesn't need advisory locks (single-writer)
119
+ yield true
120
+ end
121
+ end
122
+
123
+ def with_postgres_lock(db)
124
+ acquired = db.get(Sequel.function(:pg_try_advisory_lock, MIGRATION_LOCK_KEY))
125
+ begin
126
+ yield acquired
127
+ ensure
128
+ db.get(Sequel.function(:pg_advisory_unlock, MIGRATION_LOCK_KEY)) if acquired
129
+ end
130
+ end
131
+
132
+ def with_mysql_lock(db)
133
+ acquired = db.get(Sequel.function(:GET_LOCK, 'shikibu_migration', 0)) == 1
134
+ begin
135
+ yield acquired
136
+ ensure
137
+ db.get(Sequel.function(:RELEASE_LOCK, 'shikibu_migration')) if acquired
138
+ end
139
+ end
140
+
141
+ def apply_migration(db, file)
142
+ sql = File.read(file)
143
+
144
+ # Extract migrate:up section
145
+ up_sql = extract_up_section(sql)
146
+ return if up_sql.nil? || up_sql.strip.empty?
147
+
148
+ # Execute each statement
149
+ statements = split_statements(up_sql)
150
+ statements.each do |statement|
151
+ next if statement.strip.empty?
152
+
153
+ begin
154
+ db.run(statement)
155
+ rescue Sequel::DatabaseError => e
156
+ # Ignore "table already exists" errors for idempotent migrations
157
+ raise unless e.message.include?('already exists') ||
158
+ e.message.include?('duplicate')
159
+ end
160
+ end
161
+ end
162
+
163
+ def extract_up_section(sql)
164
+ # Split by "-- migrate:down" and take the first part
165
+ parts = sql.split(/--\s*migrate:down/i)
166
+ up_section = parts.first
167
+
168
+ # Remove "-- migrate:up" marker
169
+ up_section&.sub(/--\s*migrate:up/i, '')
170
+ end
171
+
172
+ def split_statements(sql)
173
+ # Split by semicolon at end of line
174
+ sql.split(/;\s*$/).map(&:strip).reject(&:empty?)
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end