hookarmor 1.0.3 → 1.1.1
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.
- package/LICENSE +21 -0
- package/README.md +305 -260
- package/bin/hookarmor.js +175 -133
- package/package.json +52 -52
- package/src/alerter.js +72 -63
- package/src/dispatcher.js +429 -356
- package/src/netguard.js +117 -0
- package/src/public/index.html +96 -40
- package/src/public/landing.html +2 -2
- package/src/server.js +593 -393
- package/src/storage.js +355 -266
- package/src/worker.js +72 -62
package/src/storage.js
CHANGED
|
@@ -1,266 +1,355 @@
|
|
|
1
|
-
const Database = require('better-sqlite3');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
`);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
return
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
FROM events
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
1
|
+
const Database = require('better-sqlite3');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
// SQLite datetime() format (UTC, "YYYY-MM-DD HH:MM:SS") so stored times compare correctly with datetime('now')
|
|
6
|
+
function toSqliteTime(date) {
|
|
7
|
+
return date.toISOString().slice(0, 19).replace('T', ' ');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
class Storage {
|
|
11
|
+
constructor(dbPath) {
|
|
12
|
+
if (!dbPath) {
|
|
13
|
+
const dataDir = process.env.HOOKARMOR_DATA_DIR || path.join(process.cwd(), 'data');
|
|
14
|
+
if (!fs.existsSync(dataDir)) {
|
|
15
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
dbPath = path.join(dataDir, 'hookarmor.db');
|
|
18
|
+
}
|
|
19
|
+
this.db = new Database(dbPath);
|
|
20
|
+
this.init();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
init() {
|
|
24
|
+
try {
|
|
25
|
+
this.db.pragma('journal_mode = WAL');
|
|
26
|
+
// FULL fsyncs every commit: an event acknowledged with 200 OK survives power loss
|
|
27
|
+
this.db.pragma('synchronous = FULL');
|
|
28
|
+
} catch (e) {}
|
|
29
|
+
|
|
30
|
+
this.db.exec(`
|
|
31
|
+
CREATE TABLE IF NOT EXISTS endpoints (
|
|
32
|
+
id TEXT PRIMARY KEY,
|
|
33
|
+
name TEXT NOT NULL,
|
|
34
|
+
target_url TEXT NOT NULL,
|
|
35
|
+
secret TEXT,
|
|
36
|
+
alert_webhook_url TEXT,
|
|
37
|
+
auto_retry INTEGER DEFAULT 1,
|
|
38
|
+
max_retries INTEGER DEFAULT 5,
|
|
39
|
+
concurrency_limit INTEGER DEFAULT 5,
|
|
40
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
44
|
+
id TEXT PRIMARY KEY,
|
|
45
|
+
endpoint_id TEXT NOT NULL,
|
|
46
|
+
idempotency_key TEXT,
|
|
47
|
+
provider TEXT DEFAULT 'generic',
|
|
48
|
+
event_type TEXT,
|
|
49
|
+
headers TEXT NOT NULL,
|
|
50
|
+
raw_body TEXT NOT NULL,
|
|
51
|
+
status TEXT NOT NULL, -- 'pending', 'replaying' (claimed / in flight), 'delivered', 'failed'
|
|
52
|
+
attempts INTEGER DEFAULT 0,
|
|
53
|
+
last_status_code INTEGER,
|
|
54
|
+
last_error TEXT,
|
|
55
|
+
last_latency_ms INTEGER,
|
|
56
|
+
next_retry_at TEXT,
|
|
57
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
58
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
59
|
+
FOREIGN KEY (endpoint_id) REFERENCES endpoints(id)
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
CREATE TABLE IF NOT EXISTS delivery_attempts (
|
|
63
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
64
|
+
event_id TEXT NOT NULL,
|
|
65
|
+
status_code INTEGER,
|
|
66
|
+
response_body TEXT,
|
|
67
|
+
error_message TEXT,
|
|
68
|
+
latency_ms INTEGER,
|
|
69
|
+
attempted_at TEXT DEFAULT (datetime('now')),
|
|
70
|
+
FOREIGN KEY (event_id) REFERENCES events(id)
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE TABLE IF NOT EXISTS waitlist (
|
|
74
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
75
|
+
email TEXT UNIQUE NOT NULL,
|
|
76
|
+
source TEXT DEFAULT 'website',
|
|
77
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_events_status ON events(status);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_events_endpoint ON events(endpoint_id);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_events_idempotency ON events(endpoint_id, idempotency_key);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_events_retry ON events(status, next_retry_at);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_attempts_event ON delivery_attempts(event_id);
|
|
86
|
+
`);
|
|
87
|
+
|
|
88
|
+
// Migration: whether the event's signature was verified at ingress
|
|
89
|
+
const eventColumns = this.db.prepare('PRAGMA table_info(events)').all().map((c) => c.name);
|
|
90
|
+
if (!eventColumns.includes('verified')) {
|
|
91
|
+
this.db.exec('ALTER TABLE events ADD COLUMN verified INTEGER DEFAULT 0');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Upsert. Omitting secret or alertWebhookUrl (undefined) keeps the stored value; '' clears it.
|
|
96
|
+
createEndpoint({ id, name, targetUrl, secret, alertWebhookUrl, autoRetry = 1, maxRetries = 5, concurrencyLimit = 5 }) {
|
|
97
|
+
const existing = this.getEndpoint(id);
|
|
98
|
+
const finalSecret = secret !== undefined ? secret : (existing ? existing.secret : '');
|
|
99
|
+
const finalAlert = alertWebhookUrl !== undefined ? alertWebhookUrl : (existing ? existing.alert_webhook_url : '');
|
|
100
|
+
this.db.prepare(`
|
|
101
|
+
INSERT INTO endpoints (id, name, target_url, secret, alert_webhook_url, auto_retry, max_retries, concurrency_limit)
|
|
102
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
103
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
104
|
+
name = excluded.name,
|
|
105
|
+
target_url = excluded.target_url,
|
|
106
|
+
secret = excluded.secret,
|
|
107
|
+
alert_webhook_url = excluded.alert_webhook_url,
|
|
108
|
+
auto_retry = excluded.auto_retry,
|
|
109
|
+
max_retries = excluded.max_retries,
|
|
110
|
+
concurrency_limit = excluded.concurrency_limit
|
|
111
|
+
`).run(id, name, targetUrl, finalSecret || '', finalAlert || '', autoRetry ? 1 : 0, maxRetries, concurrencyLimit || 5);
|
|
112
|
+
return this.getEndpoint(id);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getEndpoint(id) {
|
|
116
|
+
return this.db.prepare('SELECT * FROM endpoints WHERE id = ?').get(id);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
listEndpoints() {
|
|
120
|
+
return this.db.prepare('SELECT * FROM endpoints ORDER BY created_at DESC').all();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
deleteEndpoint(id) {
|
|
124
|
+
const info = this.db.prepare('DELETE FROM endpoints WHERE id = ?').run(id);
|
|
125
|
+
return info.changes > 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Any stored copy counts: once HookArmor has custody, a provider re-send is redundant
|
|
129
|
+
findEventByIdempotencyKey(endpointId, idempotencyKey) {
|
|
130
|
+
if (!idempotencyKey) return null;
|
|
131
|
+
const row = this.db.prepare('SELECT * FROM events WHERE endpoint_id = ? AND idempotency_key = ? ORDER BY created_at ASC LIMIT 1').get(endpointId, idempotencyKey);
|
|
132
|
+
if (!row) return null;
|
|
133
|
+
return { ...row, headers: JSON.parse(row.headers) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
saveEvent({ id, endpointId, idempotencyKey = null, provider, eventType, headers, rawBody, status = 'pending', verified = false }) {
|
|
137
|
+
const rawBuffer = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8');
|
|
138
|
+
const stmt = this.db.prepare(`
|
|
139
|
+
INSERT INTO events (id, endpoint_id, idempotency_key, provider, event_type, headers, raw_body, status, attempts, verified, created_at, updated_at)
|
|
140
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, datetime('now'), datetime('now'))
|
|
141
|
+
`);
|
|
142
|
+
stmt.run(
|
|
143
|
+
id,
|
|
144
|
+
endpointId,
|
|
145
|
+
idempotencyKey,
|
|
146
|
+
provider,
|
|
147
|
+
eventType,
|
|
148
|
+
JSON.stringify(headers),
|
|
149
|
+
rawBuffer,
|
|
150
|
+
status,
|
|
151
|
+
verified ? 1 : 0
|
|
152
|
+
);
|
|
153
|
+
return this.getEvent(id);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
getPendingCount() {
|
|
157
|
+
return this.db.prepare("SELECT count(*) as count FROM events WHERE status IN ('pending', 'replaying')").get().count;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
recordAttempt({ eventId, statusCode, responseBody = '', errorMessage = '', latencyMs = 0, nextRetryAt = null }) {
|
|
161
|
+
const insertAttempt = this.db.prepare(`
|
|
162
|
+
INSERT INTO delivery_attempts (event_id, status_code, response_body, error_message, latency_ms, attempted_at)
|
|
163
|
+
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
|
164
|
+
`);
|
|
165
|
+
const isSuccess = statusCode >= 200 && statusCode < 300;
|
|
166
|
+
const newStatus = isSuccess ? 'delivered' : 'failed';
|
|
167
|
+
|
|
168
|
+
const updateEvent = this.db.prepare(`
|
|
169
|
+
UPDATE events
|
|
170
|
+
SET status = ?,
|
|
171
|
+
attempts = attempts + 1,
|
|
172
|
+
last_status_code = ?,
|
|
173
|
+
last_error = ?,
|
|
174
|
+
last_latency_ms = ?,
|
|
175
|
+
next_retry_at = ?,
|
|
176
|
+
updated_at = datetime('now')
|
|
177
|
+
WHERE id = ?
|
|
178
|
+
`);
|
|
179
|
+
|
|
180
|
+
this.db.transaction(() => {
|
|
181
|
+
insertAttempt.run(eventId, statusCode, responseBody.slice(0, 4000), errorMessage.slice(0, 1000), latencyMs);
|
|
182
|
+
updateEvent.run(newStatus, statusCode, errorMessage || (isSuccess ? null : `HTTP ${statusCode}`), latencyMs, nextRetryAt, eventId);
|
|
183
|
+
})();
|
|
184
|
+
|
|
185
|
+
return this.getEvent(eventId);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
getEvent(id) {
|
|
189
|
+
const row = this.db.prepare('SELECT * FROM events WHERE id = ?').get(id);
|
|
190
|
+
if (!row) return null;
|
|
191
|
+
return {
|
|
192
|
+
...row,
|
|
193
|
+
headers: JSON.parse(row.headers)
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
getAttempts(eventId) {
|
|
198
|
+
return this.db.prepare('SELECT * FROM delivery_attempts WHERE event_id = ? ORDER BY id DESC').all(eventId);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
deleteEvent(id) {
|
|
202
|
+
const deleteTx = this.db.transaction((eventId) => {
|
|
203
|
+
this.db.prepare('DELETE FROM delivery_attempts WHERE event_id = ?').run(eventId);
|
|
204
|
+
return this.db.prepare('DELETE FROM events WHERE id = ?').run(eventId).changes > 0;
|
|
205
|
+
});
|
|
206
|
+
return deleteTx(id);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
listEvents({ endpointId = null, status = null, limit = 50, offset = 0 } = {}) {
|
|
210
|
+
let sql = 'SELECT * FROM events WHERE 1=1';
|
|
211
|
+
const params = [];
|
|
212
|
+
|
|
213
|
+
if (endpointId) {
|
|
214
|
+
sql += ' AND endpoint_id = ?';
|
|
215
|
+
params.push(endpointId);
|
|
216
|
+
}
|
|
217
|
+
if (status === 'pending') {
|
|
218
|
+
sql += " AND status IN ('pending', 'replaying')";
|
|
219
|
+
} else if (status) {
|
|
220
|
+
sql += ' AND status = ?';
|
|
221
|
+
params.push(status);
|
|
222
|
+
}
|
|
223
|
+
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
|
|
224
|
+
params.push(Math.min(Math.max(limit, 1), 500), Math.max(offset, 0));
|
|
225
|
+
|
|
226
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
227
|
+
return rows.map(r => ({
|
|
228
|
+
...r,
|
|
229
|
+
headers: JSON.parse(r.headers)
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
getStats() {
|
|
234
|
+
const total = this.db.prepare('SELECT count(*) as count FROM events').get().count;
|
|
235
|
+
const delivered = this.db.prepare("SELECT count(*) as count FROM events WHERE status = 'delivered'").get().count;
|
|
236
|
+
const failed = this.db.prepare("SELECT count(*) as count FROM events WHERE status = 'failed'").get().count;
|
|
237
|
+
const pending = this.db.prepare("SELECT count(*) as count FROM events WHERE status IN ('pending', 'replaying')").get().count;
|
|
238
|
+
const avgLatency = this.db.prepare('SELECT avg(last_latency_ms) as avg_lat FROM events WHERE last_latency_ms IS NOT NULL').get().avg_lat || 0;
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
total,
|
|
242
|
+
delivered,
|
|
243
|
+
failed,
|
|
244
|
+
pending,
|
|
245
|
+
avgLatencyMs: Math.round(avgLatency)
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Manual "replay all": every failed event, including ones that exhausted automatic retries
|
|
250
|
+
getFailedEventsForReplay(endpointId = null, limit = 500) {
|
|
251
|
+
let sql = "SELECT * FROM events WHERE status = 'failed'";
|
|
252
|
+
const params = [];
|
|
253
|
+
if (endpointId) {
|
|
254
|
+
sql += ' AND endpoint_id = ?';
|
|
255
|
+
params.push(endpointId);
|
|
256
|
+
}
|
|
257
|
+
sql += ' ORDER BY created_at ASC LIMIT ?';
|
|
258
|
+
params.push(limit);
|
|
259
|
+
return this.db.prepare(sql).all(...params).map(r => ({
|
|
260
|
+
...r,
|
|
261
|
+
headers: JSON.parse(r.headers)
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
countFailed(endpointId = null) {
|
|
266
|
+
if (endpointId) {
|
|
267
|
+
return this.db.prepare("SELECT count(*) AS n FROM events WHERE status = 'failed' AND endpoint_id = ?").get(endpointId).n;
|
|
268
|
+
}
|
|
269
|
+
return this.db.prepare("SELECT count(*) AS n FROM events WHERE status = 'failed'").get().n;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
getEventsDueForRetry(limit = 25) {
|
|
273
|
+
// datetime() normalises both legacy ISO-8601 values and SQLite-format values before comparing
|
|
274
|
+
const sql = `
|
|
275
|
+
SELECT e.*, ep.max_retries, ep.target_url, ep.secret, ep.alert_webhook_url
|
|
276
|
+
FROM events e
|
|
277
|
+
JOIN endpoints ep ON e.endpoint_id = ep.id
|
|
278
|
+
WHERE (
|
|
279
|
+
(e.status = 'failed' AND ep.auto_retry = 1 AND e.attempts < ep.max_retries AND e.next_retry_at IS NOT NULL AND datetime(e.next_retry_at) <= datetime('now'))
|
|
280
|
+
OR
|
|
281
|
+
(e.status = 'pending' AND ep.auto_retry = 1 AND e.created_at <= datetime('now', '-20 seconds'))
|
|
282
|
+
)
|
|
283
|
+
ORDER BY e.created_at ASC
|
|
284
|
+
LIMIT ?
|
|
285
|
+
`;
|
|
286
|
+
const rows = this.db.prepare(sql).all(limit);
|
|
287
|
+
return rows.map(r => ({
|
|
288
|
+
...r,
|
|
289
|
+
headers: JSON.parse(r.headers)
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
claimEventForRetry(eventId) {
|
|
294
|
+
const info = this.db.prepare(`
|
|
295
|
+
UPDATE events
|
|
296
|
+
SET status = 'replaying', updated_at = datetime('now')
|
|
297
|
+
WHERE id = ? AND status IN ('failed', 'pending')
|
|
298
|
+
`).run(eventId);
|
|
299
|
+
return info.changes > 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Manual replay may re-send delivered events, but never one that is already in flight
|
|
303
|
+
claimEventForManualReplay(eventId) {
|
|
304
|
+
const info = this.db.prepare(`
|
|
305
|
+
UPDATE events
|
|
306
|
+
SET status = 'replaying', updated_at = datetime('now')
|
|
307
|
+
WHERE id = ? AND status IN ('failed', 'pending', 'delivered')
|
|
308
|
+
`).run(eventId);
|
|
309
|
+
return info.changes > 0;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Run once at startup: anything still 'replaying' was in flight when the process died
|
|
313
|
+
recoverInterruptedDeliveries() {
|
|
314
|
+
return this.db.prepare(`
|
|
315
|
+
UPDATE events
|
|
316
|
+
SET status = 'failed',
|
|
317
|
+
next_retry_at = datetime('now'),
|
|
318
|
+
last_error = COALESCE(last_error, 'Delivery interrupted by restart'),
|
|
319
|
+
updated_at = datetime('now')
|
|
320
|
+
WHERE status = 'replaying'
|
|
321
|
+
`).run().changes;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Delete delivered events (and their attempt logs) older than `days`. Failed events are kept.
|
|
325
|
+
pruneEvents(days) {
|
|
326
|
+
if (!days || days <= 0) return 0;
|
|
327
|
+
const cutoff = `-${Math.floor(days)} days`;
|
|
328
|
+
return this.db.transaction(() => {
|
|
329
|
+
this.db.prepare(`
|
|
330
|
+
DELETE FROM delivery_attempts WHERE event_id IN (
|
|
331
|
+
SELECT id FROM events WHERE status = 'delivered' AND created_at < datetime('now', ?)
|
|
332
|
+
)
|
|
333
|
+
`).run(cutoff);
|
|
334
|
+
return this.db.prepare("DELETE FROM events WHERE status = 'delivered' AND created_at < datetime('now', ?)").run(cutoff).changes;
|
|
335
|
+
})();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
addWaitlist(email, source = 'website') {
|
|
339
|
+
const stmt = this.db.prepare(`
|
|
340
|
+
INSERT INTO waitlist (email, source, created_at)
|
|
341
|
+
VALUES (?, ?, datetime('now'))
|
|
342
|
+
ON CONFLICT(email) DO UPDATE SET created_at = datetime('now')
|
|
343
|
+
`);
|
|
344
|
+
stmt.run(email, source);
|
|
345
|
+
return { email, source, status: 'registered' };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
listWaitlist() {
|
|
349
|
+
return this.db.prepare('SELECT * FROM waitlist ORDER BY created_at DESC').all();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
Storage.toSqliteTime = toSqliteTime;
|
|
354
|
+
|
|
355
|
+
module.exports = Storage;
|