pg_sql_triggers 1.1.1 → 1.2.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,136 @@
1
+ <% form_id = "trigger-re-execute-#{trigger.id}-form" %>
2
+
3
+ <%= form_with url: re_execute_trigger_path(trigger), method: :post, local: true, id: form_id, style: "margin: 0; display: inline-block;" do |f| %>
4
+ <%= f.hidden_field :redirect_to, value: params[:redirect_to] || trigger_path(trigger) %>
5
+
6
+ <button type="button" onclick="showReExecuteModal('<%= form_id %>')"
7
+ style="padding: 0.75rem 1.5rem; background: #ffc107; color: #000; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600;">
8
+ Re-Execute Trigger
9
+ </button>
10
+ <% end %>
11
+
12
+ <!-- Re-Execute Modal -->
13
+ <div id="<%= form_id %>-modal" style="display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4);">
14
+ <div style="background-color: #fefefe; margin: 3% auto; padding: 0; border-radius: 8px; width: 90%; max-width: 800px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
15
+ <!-- Header -->
16
+ <div style="background: #ffc107; color: #000; padding: 1.5rem; border-radius: 8px 8px 0 0;">
17
+ <div style="display: flex; justify-content: space-between; align-items: center;">
18
+ <h3 style="margin: 0;">🔄 Re-Execute Trigger</h3>
19
+ <button type="button" onclick="closeReExecuteModal('<%= form_id %>')"
20
+ style="background: none; border: none; color: #000; font-size: 1.5rem; cursor: pointer; padding: 0; width: 30px; height: 30px;">
21
+ &times;
22
+ </button>
23
+ </div>
24
+ </div>
25
+
26
+ <!-- Body -->
27
+ <div style="padding: 1.5rem;">
28
+ <div style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 1rem; margin-bottom: 1.5rem;">
29
+ <strong style="color: #856404;">Notice:</strong>
30
+ <p style="margin: 0.5rem 0 0 0; color: #856404;">
31
+ This will drop and re-create trigger '<strong><%= trigger.trigger_name %></strong>' based on the expected definition from the registry.
32
+ This can fix drift issues.
33
+ </p>
34
+ </div>
35
+
36
+ <!-- Show Drift Diff -->
37
+ <% if drift_info[:expected_sql].present? %>
38
+ <div style="margin-bottom: 1.5rem;">
39
+ <h4 style="margin-bottom: 1rem;">SQL Comparison</h4>
40
+
41
+ <div style="margin-bottom: 1rem;">
42
+ <div style="color: #28a745; font-weight: 600; margin-bottom: 0.5rem;">✓ Expected SQL (will be applied):</div>
43
+ <pre style="background: #d4edda; padding: 1rem; border-radius: 4px; overflow-x: auto; border: 1px solid #c3e6cb; font-size: 0.85rem; max-height: 200px;"><code><%= drift_info[:expected_sql] %></code></pre>
44
+ </div>
45
+
46
+ <div>
47
+ <div style="color: #dc3545; font-weight: 600; margin-bottom: 0.5rem;">✗ Current SQL (will be replaced):</div>
48
+ <pre style="background: #f8d7da; padding: 1rem; border-radius: 4px; overflow-x: auto; border: 1px solid #f5c6cb; font-size: 0.85rem; max-height: 200px;"><code><%= drift_info[:actual_sql] || "Not found in database" %></code></pre>
49
+ </div>
50
+ </div>
51
+ <% end %>
52
+
53
+ <form id="<%= form_id %>-reason-form">
54
+ <div style="margin-bottom: 1rem;">
55
+ <label for="<%= form_id %>-reason" style="display: block; font-weight: 600; margin-bottom: 0.5rem;">
56
+ Reason <span style="color: #dc3545;">*</span>
57
+ </label>
58
+ <textarea id="<%= form_id %>-reason" name="reason" required rows="2"
59
+ placeholder="Why are you re-executing this trigger? This will be logged."
60
+ style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-family: inherit;"></textarea>
61
+ </div>
62
+
63
+ <% if kill_switch_active? %>
64
+ <div style="margin-bottom: 1rem;">
65
+ <label for="<%= form_id %>-confirmation" style="display: block; font-weight: 600; margin-bottom: 0.5rem;">
66
+ Confirmation Text <span style="color: #dc3545;">*</span>
67
+ </label>
68
+ <input type="text" id="<%= form_id %>-confirmation" name="confirmation_text" required
69
+ placeholder="<%= expected_confirmation_text(:trigger_re_execute) %>"
70
+ autocomplete="off"
71
+ style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-family: 'Monaco', 'Menlo', monospace;">
72
+ <small style="color: #6c757d; display: block; margin-top: 0.25rem;">
73
+ Type: <code><%= expected_confirmation_text(:trigger_re_execute) %></code>
74
+ </small>
75
+ </div>
76
+ <% end %>
77
+
78
+ <div style="display: flex; gap: 1rem; justify-content: flex-end;">
79
+ <button type="button" onclick="closeReExecuteModal('<%= form_id %>')"
80
+ style="padding: 0.75rem 1.5rem; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer;">
81
+ Cancel
82
+ </button>
83
+ <button type="button" onclick="submitReExecute('<%= form_id %>')"
84
+ style="padding: 0.75rem 1.5rem; background: #ffc107; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">
85
+ Re-Execute Trigger
86
+ </button>
87
+ </div>
88
+ </form>
89
+ </div>
90
+ </div>
91
+ </div>
92
+
93
+ <script>
94
+ function showReExecuteModal(formId) {
95
+ document.getElementById(formId + '-modal').style.display = 'block';
96
+ }
97
+
98
+ function closeReExecuteModal(formId) {
99
+ document.getElementById(formId + '-modal').style.display = 'none';
100
+ }
101
+
102
+ function submitReExecute(formId) {
103
+ const form = document.getElementById(formId);
104
+ const reasonForm = document.getElementById(formId + '-reason-form');
105
+ const reason = document.getElementById(formId + '-reason').value;
106
+
107
+ if (!reason.trim()) {
108
+ alert('Please provide a reason for re-executing this trigger.');
109
+ return;
110
+ }
111
+
112
+ <% if kill_switch_active? %>
113
+ const confirmation = document.getElementById(formId + '-confirmation').value;
114
+ if (!confirmation.trim()) {
115
+ alert('Please provide the confirmation text.');
116
+ return;
117
+ }
118
+
119
+ // Add confirmation to main form
120
+ const confirmationInput = document.createElement('input');
121
+ confirmationInput.type = 'hidden';
122
+ confirmationInput.name = 'confirmation_text';
123
+ confirmationInput.value = confirmation;
124
+ form.appendChild(confirmationInput);
125
+ <% end %>
126
+
127
+ // Add reason to main form
128
+ const reasonInput = document.createElement('input');
129
+ reasonInput.type = 'hidden';
130
+ reasonInput.name = 'reason';
131
+ reasonInput.value = reason;
132
+ form.appendChild(reasonInput);
133
+
134
+ form.submit();
135
+ }
136
+ </script>
@@ -0,0 +1,186 @@
1
+ <div style="margin-bottom: 2rem;">
2
+ <h2>Trigger Details: <%= @trigger.trigger_name %></h2>
3
+ <%= link_to "← Back to Dashboard", dashboard_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none; margin-right: 1rem;" %>
4
+ <%= link_to "View Table", table_path(@trigger.table_name), class: "btn", style: "background: #007bff; color: white; text-decoration: none;" %>
5
+ </div>
6
+
7
+ <!-- Drift Warning -->
8
+ <% if @drift_info[:has_drift] %>
9
+ <div style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 1.5rem; border-radius: 4px; margin-bottom: 2rem;">
10
+ <h3 style="margin-top: 0; color: #856404;">⚠ Drift Detected</h3>
11
+ <p style="color: #856404; margin-bottom: 0;">
12
+ This trigger has drifted from its expected state. Type: <strong><%= @drift_info[:drift_type] %></strong>
13
+ </p>
14
+ </div>
15
+ <% end %>
16
+
17
+ <!-- Trigger Summary -->
18
+ <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem;">
19
+ <h3 style="margin-top: 0;">Summary</h3>
20
+
21
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem; margin-bottom: 1.5rem;">
22
+ <div>
23
+ <div style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.25rem;">Status</div>
24
+ <div>
25
+ <% if @trigger.enabled %>
26
+ <span class="badge badge-success">Enabled</span>
27
+ <% else %>
28
+ <span class="badge badge-danger">Disabled</span>
29
+ <% end %>
30
+ </div>
31
+ </div>
32
+
33
+ <div>
34
+ <div style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.25rem;">Table</div>
35
+ <div><strong><%= @trigger.table_name %></strong></div>
36
+ </div>
37
+
38
+ <div>
39
+ <div style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.25rem;">Version</div>
40
+ <div><strong><%= @trigger.version %></strong></div>
41
+ </div>
42
+
43
+ <div>
44
+ <div style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.25rem;">Source</div>
45
+ <div><span class="badge badge-info"><%= @trigger.source %></span></div>
46
+ </div>
47
+ </div>
48
+
49
+ <% if @trigger.environment.present? %>
50
+ <div style="margin-bottom: 1rem;">
51
+ <div style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.25rem;">Environment</div>
52
+ <div><strong><%= @trigger.environment %></strong></div>
53
+ </div>
54
+ <% end %>
55
+
56
+ <% if @trigger.installed_at.present? %>
57
+ <div style="margin-bottom: 1rem;">
58
+ <div style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.25rem;">Installed At</div>
59
+ <div><%= @trigger.installed_at.strftime("%Y-%m-%d %H:%M:%S %Z") %></div>
60
+ </div>
61
+ <% end %>
62
+
63
+ <div>
64
+ <div style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.25rem;">Created At</div>
65
+ <div><%= @trigger.created_at.strftime("%Y-%m-%d %H:%M:%S %Z") %></div>
66
+ </div>
67
+ </div>
68
+
69
+ <!-- Trigger Definition -->
70
+ <% if @trigger.definition.present? %>
71
+ <% definition = JSON.parse(@trigger.definition) rescue {} %>
72
+ <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem;">
73
+ <h3 style="margin-top: 0;">Trigger Configuration</h3>
74
+
75
+ <div style="margin-bottom: 1rem;">
76
+ <strong>Function:</strong>
77
+ <code style="background: #e9ecef; padding: 0.25rem 0.5rem; border-radius: 2px;"><%= definition["function_name"] || "N/A" %></code>
78
+ </div>
79
+
80
+ <% if definition["timing"].present? %>
81
+ <div style="margin-bottom: 1rem;">
82
+ <strong>Timing:</strong>
83
+ <span class="badge badge-info"><%= definition["timing"].upcase %></span>
84
+ </div>
85
+ <% end %>
86
+
87
+ <% if definition["events"].present? %>
88
+ <div style="margin-bottom: 1rem;">
89
+ <strong>Events:</strong>
90
+ <% definition["events"].each do |event| %>
91
+ <span class="badge badge-info" style="margin-left: 0.25rem;"><%= event.upcase %></span>
92
+ <% end %>
93
+ </div>
94
+ <% end %>
95
+
96
+ <% if definition["condition"].present? %>
97
+ <div style="margin-bottom: 1rem;">
98
+ <strong>Condition (WHEN):</strong>
99
+ <pre style="background: #f8f9fa; padding: 0.5rem; border-radius: 4px; margin-top: 0.25rem; overflow-x: auto;"><code><%= definition["condition"] %></code></pre>
100
+ </div>
101
+ <% end %>
102
+
103
+ <% if definition["environments"].present? %>
104
+ <div>
105
+ <strong>Environments:</strong>
106
+ <% definition["environments"].each do |env| %>
107
+ <span class="badge badge-secondary" style="margin-left: 0.25rem;"><%= env %></span>
108
+ <% end %>
109
+ </div>
110
+ <% end %>
111
+ </div>
112
+ <% end %>
113
+
114
+ <!-- SQL Diff (if drift detected) -->
115
+ <% if @drift_info[:has_drift] && @drift_info[:expected_sql].present? %>
116
+ <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem;">
117
+ <h3 style="margin-top: 0;">SQL Drift Comparison</h3>
118
+
119
+ <div style="margin-bottom: 1.5rem;">
120
+ <h4 style="color: #28a745; margin-bottom: 0.5rem;">Expected SQL (from DSL)</h4>
121
+ <pre style="background: #d4edda; padding: 1rem; border-radius: 4px; overflow-x: auto; border: 1px solid #c3e6cb;"><code><%= @drift_info[:expected_sql] %></code></pre>
122
+ </div>
123
+
124
+ <div>
125
+ <h4 style="color: #dc3545; margin-bottom: 0.5rem;">Actual SQL (from database)</h4>
126
+ <pre style="background: #f8d7da; padding: 1rem; border-radius: 4px; overflow-x: auto; border: 1px solid #f5c6cb;"><code><%= @drift_info[:actual_sql] || "Not found in database" %></code></pre>
127
+ </div>
128
+ </div>
129
+ <% end %>
130
+
131
+ <!-- Function Body -->
132
+ <% if @trigger.function_body.present? %>
133
+ <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem;">
134
+ <h3 style="margin-top: 0;">Function Body</h3>
135
+ <pre style="background: #f8f9fa; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code><%= @trigger.function_body %></code></pre>
136
+ </div>
137
+ <% end %>
138
+
139
+ <!-- Action Buttons -->
140
+ <% if PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger) %>
141
+ <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
142
+ <h3 style="margin-top: 0;">Actions</h3>
143
+
144
+ <div style="display: flex; gap: 1rem; flex-wrap: wrap;">
145
+ <% if @trigger.enabled %>
146
+ <% form_id = "trigger-detail-disable-form" %>
147
+ <%= form_with url: disable_trigger_path(@trigger), method: :post, local: true, id: form_id, style: "margin: 0;" do |f| %>
148
+ <%= f.hidden_field :redirect_to, value: trigger_path(@trigger) %>
149
+ <button type="button" onclick="showKillSwitchModal('<%= form_id %>')"
150
+ style="padding: 0.75rem 1.5rem; background: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600;">
151
+ Disable Trigger
152
+ </button>
153
+ <% end %>
154
+ <%= render 'pg_sql_triggers/shared/confirmation_modal',
155
+ operation: :ui_trigger_disable,
156
+ form_id: form_id,
157
+ title: 'Disable Trigger',
158
+ message: "Are you sure you want to disable trigger '#{@trigger.trigger_name}'?" %>
159
+ <% else %>
160
+ <% form_id = "trigger-detail-enable-form" %>
161
+ <%= form_with url: enable_trigger_path(@trigger), method: :post, local: true, id: form_id, style: "margin: 0;" do |f| %>
162
+ <%= f.hidden_field :redirect_to, value: trigger_path(@trigger) %>
163
+ <button type="button" onclick="showKillSwitchModal('<%= form_id %>')"
164
+ style="padding: 0.75rem 1.5rem; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600;">
165
+ Enable Trigger
166
+ </button>
167
+ <% end %>
168
+ <%= render 'pg_sql_triggers/shared/confirmation_modal',
169
+ operation: :ui_trigger_enable,
170
+ form_id: form_id,
171
+ title: 'Enable Trigger',
172
+ message: "Are you sure you want to enable trigger '#{@trigger.trigger_name}'?" %>
173
+ <% end %>
174
+
175
+ <!-- Re-execute button (for drifted triggers) -->
176
+ <% if @drift_info[:has_drift] && PgSqlTriggers::Permissions.can?(current_actor, :drop_trigger) %>
177
+ <%= render 'pg_sql_triggers/triggers/re_execute_modal', trigger: @trigger, drift_info: @drift_info %>
178
+ <% end %>
179
+
180
+ <!-- Drop button (Admin only) -->
181
+ <% if PgSqlTriggers::Permissions.can?(current_actor, :drop_trigger) %>
182
+ <%= render 'pg_sql_triggers/triggers/drop_modal', trigger: @trigger %>
183
+ <% end %>
184
+ </div>
185
+ </div>
186
+ <% end %>
data/config/routes.rb CHANGED
@@ -28,6 +28,15 @@ begin
28
28
  post :redo
29
29
  end
30
30
  end
31
+
32
+ resources :triggers, only: [:show] do
33
+ member do
34
+ post :enable
35
+ post :disable
36
+ post :drop
37
+ post :re_execute
38
+ end
39
+ end
31
40
  end
32
41
  rescue ArgumentError => e
33
42
  # Ignore duplicate route errors (routes may already be drawn in tests)
@@ -114,6 +114,102 @@ end
114
114
 
115
115
  **Returns**: `true` if all triggers are valid
116
116
 
117
+ ### `PgSqlTriggers::Registry.enable(trigger_name, actor:, confirmation: nil)`
118
+
119
+ Enables a trigger with permission and kill switch checks.
120
+
121
+ ```ruby
122
+ # Enable trigger
123
+ PgSqlTriggers::Registry.enable(
124
+ "users_email_validation",
125
+ actor: { type: "user", id: "admin@example.com" },
126
+ confirmation: "EXECUTE TRIGGER_ENABLE"
127
+ )
128
+
129
+ # With current user as actor
130
+ actor = { type: "user", id: current_user.email }
131
+ PgSqlTriggers::Registry.enable("billing_trigger", actor: actor, confirmation: "EXECUTE TRIGGER_ENABLE")
132
+ ```
133
+
134
+ **Parameters**:
135
+ - `trigger_name` (String): The name of the trigger to enable
136
+ - `actor` (Hash): Information about who is performing the operation (requires `:type` and `:id` keys)
137
+ - `confirmation` (String, optional): Kill switch confirmation text
138
+
139
+ **Raises**: `PgSqlTriggers::PermissionError`, `PgSqlTriggers::KillSwitchError`, `ArgumentError`
140
+
141
+ **Returns**: `true` on success
142
+
143
+ ### `PgSqlTriggers::Registry.disable(trigger_name, actor:, confirmation: nil)`
144
+
145
+ Disables a trigger with permission and kill switch checks.
146
+
147
+ ```ruby
148
+ # Disable trigger
149
+ PgSqlTriggers::Registry.disable(
150
+ "users_email_validation",
151
+ actor: { type: "user", id: "admin@example.com" },
152
+ confirmation: "EXECUTE TRIGGER_DISABLE"
153
+ )
154
+ ```
155
+
156
+ **Parameters**:
157
+ - `trigger_name` (String): The name of the trigger to disable
158
+ - `actor` (Hash): Information about who is performing the operation
159
+ - `confirmation` (String, optional): Kill switch confirmation text
160
+
161
+ **Raises**: `PgSqlTriggers::PermissionError`, `PgSqlTriggers::KillSwitchError`, `ArgumentError`
162
+
163
+ **Returns**: `true` on success
164
+
165
+ ### `PgSqlTriggers::Registry.drop(trigger_name, actor:, reason:, confirmation: nil)`
166
+
167
+ Drops a trigger from the database and removes it from the registry.
168
+
169
+ ```ruby
170
+ # Drop trigger
171
+ PgSqlTriggers::Registry.drop(
172
+ "old_trigger",
173
+ actor: { type: "user", id: "admin@example.com" },
174
+ reason: "No longer needed in production",
175
+ confirmation: "EXECUTE TRIGGER_DROP"
176
+ )
177
+ ```
178
+
179
+ **Parameters**:
180
+ - `trigger_name` (String): The name of the trigger to drop
181
+ - `actor` (Hash): Information about who is performing the operation (Admin permission required)
182
+ - `reason` (String): Required explanation for why the trigger is being dropped (logged for audit)
183
+ - `confirmation` (String, optional): Kill switch confirmation text
184
+
185
+ **Raises**: `PgSqlTriggers::PermissionError`, `PgSqlTriggers::KillSwitchError`, `ArgumentError`
186
+
187
+ **Returns**: `true` on success
188
+
189
+ ### `PgSqlTriggers::Registry.re_execute(trigger_name, actor:, reason:, confirmation: nil)`
190
+
191
+ Re-executes a trigger by dropping and recreating it from the registry definition. Useful for fixing drifted triggers.
192
+
193
+ ```ruby
194
+ # Re-execute drifted trigger
195
+ PgSqlTriggers::Registry.re_execute(
196
+ "drifted_trigger",
197
+ actor: { type: "user", id: "admin@example.com" },
198
+ reason: "Fix drift detected in production",
199
+ confirmation: "EXECUTE TRIGGER_RE_EXECUTE"
200
+ )
201
+ ```
202
+
203
+ **Parameters**:
204
+ - `trigger_name` (String): The name of the trigger to re-execute
205
+ - `actor` (Hash): Information about who is performing the operation (Admin permission required)
206
+ - `reason` (String): Required explanation for why the trigger is being re-executed (logged for audit)
207
+ - `confirmation` (String, optional): Kill switch confirmation text
208
+
209
+ **Raises**: `PgSqlTriggers::PermissionError`, `PgSqlTriggers::KillSwitchError`, `ArgumentError`
210
+
211
+ **Returns**: `true` on success
212
+
117
213
  ## Migrator API
118
214
 
119
215
  The Migrator API manages trigger migrations programmatically.
@@ -318,6 +414,124 @@ end
318
414
 
319
415
  **Returns**: Result of the block
320
416
 
417
+ ## SQL Capsule API
418
+
419
+ The SQL Capsule API provides emergency SQL execution capabilities with safety checks.
420
+
421
+ ### `PgSqlTriggers::SQL::Capsule.new(name:, environment:, purpose:, sql:, created_at: nil)`
422
+
423
+ Creates a new SQL capsule for emergency operations.
424
+
425
+ ```ruby
426
+ capsule = PgSqlTriggers::SQL::Capsule.new(
427
+ name: "fix_user_permissions",
428
+ environment: "production",
429
+ purpose: "Emergency fix for user permission issue after deployment",
430
+ sql: "UPDATE users SET role = 'admin' WHERE email = 'admin@example.com';"
431
+ )
432
+ ```
433
+
434
+ **Parameters**:
435
+ - `name` (String): Unique name for the capsule (alphanumeric, underscores, hyphens only)
436
+ - `environment` (String): Target environment (e.g., "production", "staging")
437
+ - `purpose` (String): Description of what the capsule does and why (required for audit trail)
438
+ - `sql` (String): The SQL statement(s) to execute
439
+ - `created_at` (Time, optional): Creation timestamp (defaults to current time)
440
+
441
+ **Raises**: `ArgumentError` if validation fails
442
+
443
+ ### `capsule.checksum`
444
+
445
+ Returns the SHA256 checksum of the SQL content.
446
+
447
+ ```ruby
448
+ capsule = PgSqlTriggers::SQL::Capsule.new(name: "fix", ...)
449
+ puts capsule.checksum
450
+ # => "a3f5b8c9d2e..."
451
+ ```
452
+
453
+ **Returns**: String (SHA256 hash)
454
+
455
+ ### `capsule.to_h`
456
+
457
+ Converts the capsule to a hash for storage or serialization.
458
+
459
+ ```ruby
460
+ capsule_data = capsule.to_h
461
+ # => {
462
+ # name: "fix_user_permissions",
463
+ # environment: "production",
464
+ # purpose: "Emergency fix...",
465
+ # sql: "UPDATE users...",
466
+ # checksum: "a3f5b8c9d2e...",
467
+ # created_at: 2026-01-01 12:00:00 UTC
468
+ # }
469
+ ```
470
+
471
+ **Returns**: Hash
472
+
473
+ ## SQL Executor API
474
+
475
+ The SQL Executor API handles safe execution of SQL capsules with comprehensive logging.
476
+
477
+ ### `PgSqlTriggers::SQL::Executor.execute(capsule, actor:, confirmation: nil, dry_run: false)`
478
+
479
+ Executes a SQL capsule with safety checks and logging.
480
+
481
+ ```ruby
482
+ capsule = PgSqlTriggers::SQL::Capsule.new(
483
+ name: "emergency_fix",
484
+ environment: "production",
485
+ purpose: "Fix critical data corruption",
486
+ sql: "UPDATE orders SET status = 'completed' WHERE id IN (123, 456);"
487
+ )
488
+
489
+ # Execute the capsule
490
+ result = PgSqlTriggers::SQL::Executor.execute(
491
+ capsule,
492
+ actor: { type: "user", id: "admin@example.com" },
493
+ confirmation: "EXECUTE SQL"
494
+ )
495
+
496
+ if result[:success]
497
+ puts "SQL executed successfully"
498
+ puts "Rows affected: #{result[:data][:rows_affected]}"
499
+ else
500
+ puts "Execution failed: #{result[:message]}"
501
+ end
502
+ ```
503
+
504
+ **Parameters**:
505
+ - `capsule` (Capsule): The SQL capsule to execute
506
+ - `actor` (Hash): Information about who is executing (Admin permission required)
507
+ - `confirmation` (String, optional): Kill switch confirmation text
508
+ - `dry_run` (Boolean): If true, validates without executing (default: false)
509
+
510
+ **Returns**: Hash with `:success`, `:message`, and `:data` keys
511
+
512
+ **Raises**: Permission and kill switch errors are returned in the result hash
513
+
514
+ ### `PgSqlTriggers::SQL::Executor.execute_capsule(capsule_name, actor:, confirmation: nil, dry_run: false)`
515
+
516
+ Executes a previously stored SQL capsule by name from the registry.
517
+
518
+ ```ruby
519
+ # Execute a capsule stored in the registry
520
+ result = PgSqlTriggers::SQL::Executor.execute_capsule(
521
+ "emergency_fix",
522
+ actor: { type: "user", id: "admin@example.com" },
523
+ confirmation: "EXECUTE SQL"
524
+ )
525
+ ```
526
+
527
+ **Parameters**:
528
+ - `capsule_name` (String): Name of the capsule in the registry
529
+ - `actor` (Hash): Information about who is executing
530
+ - `confirmation` (String, optional): Kill switch confirmation text
531
+ - `dry_run` (Boolean): If true, validates without executing
532
+
533
+ **Returns**: Hash with execution results
534
+
321
535
  ## DSL API
322
536
 
323
537
  The DSL API is used to define triggers in your application.
@@ -487,17 +701,51 @@ trigger.disable!(confirmation: "EXECUTE TRIGGER_DISABLE")
487
701
 
488
702
  **Returns**: `true` on success
489
703
 
490
- #### `drop!(confirmation: nil)`
704
+ #### `drop!(reason:, confirmation: nil, actor: nil)`
491
705
 
492
- Drops the trigger from the database.
706
+ Drops the trigger from the database and removes it from the registry.
493
707
 
494
708
  ```ruby
495
- trigger = PgSqlTriggers::TriggerRegistry.find_by(trigger_name: "users_email_validation")
496
- trigger.drop!(confirmation: "EXECUTE TRIGGER_DROP")
709
+ trigger = PgSqlTriggers::TriggerRegistry.find_by(trigger_name: "old_trigger")
710
+
711
+ # Drop with reason (required)
712
+ trigger.drop!(
713
+ reason: "No longer needed in production",
714
+ confirmation: "EXECUTE TRIGGER_DROP",
715
+ actor: { type: "user", id: "admin@example.com" }
716
+ )
497
717
  ```
498
718
 
499
719
  **Parameters**:
720
+ - `reason` (String, required): Explanation for why the trigger is being dropped (logged for audit trail)
500
721
  - `confirmation` (String, optional): Kill switch confirmation text
722
+ - `actor` (Hash, optional): Information about who is performing the operation
723
+
724
+ **Raises**: `ArgumentError` if reason is blank, `PgSqlTriggers::KillSwitchError`
725
+
726
+ **Returns**: `true` on success
727
+
728
+ #### `re_execute!(reason:, confirmation: nil, actor: nil)`
729
+
730
+ Re-executes the trigger by dropping and recreating it from the registry definition. Useful for fixing drifted triggers.
731
+
732
+ ```ruby
733
+ trigger = PgSqlTriggers::TriggerRegistry.find_by(trigger_name: "drifted_trigger")
734
+
735
+ # Re-execute to fix drift
736
+ trigger.re_execute!(
737
+ reason: "Fix drift detected after manual database changes",
738
+ confirmation: "EXECUTE TRIGGER_RE_EXECUTE",
739
+ actor: { type: "user", id: "admin@example.com" }
740
+ )
741
+ ```
742
+
743
+ **Parameters**:
744
+ - `reason` (String, required): Explanation for why the trigger is being re-executed (logged for audit trail)
745
+ - `confirmation` (String, optional): Kill switch confirmation text
746
+ - `actor` (Hash, optional): Information about who is performing the operation
747
+
748
+ **Raises**: `ArgumentError` if reason is blank or function_body is missing, `PgSqlTriggers::KillSwitchError`, `StandardError`
501
749
 
502
750
  **Returns**: `true` on success
503
751