@azizikri/hookpipe 1.0.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.
- package/LICENSE +21 -0
- package/README.md +325 -0
- package/package.json +64 -0
- package/src/auth/hmac.js +100 -0
- package/src/cli/index.js +31 -0
- package/src/cli/logs.js +86 -0
- package/src/cli/replay.js +64 -0
- package/src/cli/serve.js +75 -0
- package/src/cli/test.js +135 -0
- package/src/db/index.js +60 -0
- package/src/db/migrations/001_initial.sql +107 -0
- package/src/db/queries.js +196 -0
- package/src/delivery/retry.js +63 -0
- package/src/delivery/worker.js +192 -0
- package/src/destinations/http.js +60 -0
- package/src/destinations/index.js +30 -0
- package/src/destinations/interface.js +47 -0
- package/src/pipeline-loader.js +151 -0
- package/src/plugin-loader.js +88 -0
- package/src/queue/interface.js +68 -0
- package/src/queue/sqlite-queue.js +83 -0
- package/src/server.js +124 -0
- package/src/templates/handlebars.js +21 -0
- package/src/utils/config.js +115 -0
- package/src/utils/crypto.js +44 -0
- package/src/utils/logger.js +35 -0
package/src/cli/serve.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { loadConfig } from '../utils/config.js';
|
|
2
|
+
import { createLogger } from '../utils/logger.js';
|
|
3
|
+
import { initDatabase, closeDatabase } from '../db/index.js';
|
|
4
|
+
import { SqliteQueue } from '../queue/sqlite-queue.js';
|
|
5
|
+
import { PipelineLoader } from '../pipeline-loader.js';
|
|
6
|
+
import { PluginLoader } from '../plugin-loader.js';
|
|
7
|
+
import { registry as destinationRegistry } from '../destinations/index.js';
|
|
8
|
+
import { DeliveryWorker } from '../delivery/worker.js';
|
|
9
|
+
import { createServer } from '../server.js';
|
|
10
|
+
|
|
11
|
+
export function serveCommand(program) {
|
|
12
|
+
program
|
|
13
|
+
.command('serve')
|
|
14
|
+
.description('Start the hookpipe webhook server')
|
|
15
|
+
.option('-p, --port <number>', 'Port to listen on')
|
|
16
|
+
.option('-H, --host <string>', 'Host to bind to')
|
|
17
|
+
.option('-c, --config <path>', 'Config file path')
|
|
18
|
+
.option('--pipelines <dir>', 'Pipelines directory')
|
|
19
|
+
.option('--plugins <dir>', 'Plugins directory')
|
|
20
|
+
.action(async (opts) => {
|
|
21
|
+
const overrides = {};
|
|
22
|
+
if (opts.port) overrides.port = Number(opts.port);
|
|
23
|
+
if (opts.host) overrides.host = opts.host;
|
|
24
|
+
if (opts.pipelines) overrides.pipelines = { dir: opts.pipelines };
|
|
25
|
+
if (opts.plugins) overrides.plugins = { dir: opts.plugins };
|
|
26
|
+
if (opts.config) overrides.configPath = opts.config;
|
|
27
|
+
|
|
28
|
+
const config = loadConfig(overrides);
|
|
29
|
+
const logger = createLogger({ level: config.log.level });
|
|
30
|
+
const db = initDatabase(config.db.path);
|
|
31
|
+
const queue = new SqliteQueue(db, config);
|
|
32
|
+
const pipelineLoader = new PipelineLoader(config.pipelines.dir);
|
|
33
|
+
const pluginLoader = new PluginLoader(config.plugins.dir);
|
|
34
|
+
const worker = new DeliveryWorker({
|
|
35
|
+
queue,
|
|
36
|
+
db,
|
|
37
|
+
destinationRegistry,
|
|
38
|
+
logger,
|
|
39
|
+
getPipeline: (id) => pipelineLoader.get(id),
|
|
40
|
+
config: { pollIntervalMs: config.queue?.poll_interval_ms ?? 1000, concurrency: config.queue?.concurrency ?? 5 },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const server = createServer({
|
|
44
|
+
pipelineLoader,
|
|
45
|
+
pluginLoader,
|
|
46
|
+
queue,
|
|
47
|
+
db,
|
|
48
|
+
logger,
|
|
49
|
+
config,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await pipelineLoader.loadAll();
|
|
53
|
+
await pipelineLoader.startWatching();
|
|
54
|
+
worker.start();
|
|
55
|
+
|
|
56
|
+
const host = config.host || '0.0.0.0';
|
|
57
|
+
const port = config.port || 3000;
|
|
58
|
+
|
|
59
|
+
await server.listen({ port, host });
|
|
60
|
+
logger.info(`Server listening on http://${host}:${port}`);
|
|
61
|
+
|
|
62
|
+
const shutdown = async () => {
|
|
63
|
+
logger.info('Shutting down...');
|
|
64
|
+
worker.stop();
|
|
65
|
+
pipelineLoader.stopWatching();
|
|
66
|
+
server.close();
|
|
67
|
+
closeDatabase(db);
|
|
68
|
+
logger.info('Shutdown complete');
|
|
69
|
+
process.exit(0);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
process.on('SIGTERM', shutdown);
|
|
73
|
+
process.on('SIGINT', shutdown);
|
|
74
|
+
});
|
|
75
|
+
}
|
package/src/cli/test.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { loadConfig } from '../utils/config.js';
|
|
4
|
+
import { PipelineLoader } from '../pipeline-loader.js';
|
|
5
|
+
import { PluginLoader } from '../plugin-loader.js';
|
|
6
|
+
import { verifyWebhookAuth } from '../auth/hmac.js';
|
|
7
|
+
import { renderTemplate } from '../templates/handlebars.js';
|
|
8
|
+
|
|
9
|
+
function collectHeader(value, previous) {
|
|
10
|
+
return previous.concat([value]);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function testCommand(program) {
|
|
14
|
+
program
|
|
15
|
+
.command('test')
|
|
16
|
+
.description('Dry-run a webhook through a pipeline')
|
|
17
|
+
.argument('<pipeline-id>', 'Pipeline ID to test')
|
|
18
|
+
.option('-f, --file <path>', 'JSON file to use as payload')
|
|
19
|
+
.option('-d, --data <json>', 'Inline JSON payload')
|
|
20
|
+
.option('--header <header>', 'Custom header (repeatable, format: "Key: Value")', collectHeader, [])
|
|
21
|
+
.option('--skip-auth', 'Skip HMAC authentication check')
|
|
22
|
+
.option('-c, --config <path>', 'Config file path')
|
|
23
|
+
.action(async (pipelineId, opts) => {
|
|
24
|
+
try {
|
|
25
|
+
await runTest(pipelineId, opts);
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error(`Error: ${err.message}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function runTest(pipelineId, opts) {
|
|
34
|
+
const overrides = {};
|
|
35
|
+
if (opts.config) overrides.configPath = opts.config;
|
|
36
|
+
|
|
37
|
+
const config = loadConfig(overrides);
|
|
38
|
+
const pipelineLoader = new PipelineLoader(config.pipelines.dir);
|
|
39
|
+
const pluginLoader = new PluginLoader(config.plugins.dir);
|
|
40
|
+
|
|
41
|
+
await pipelineLoader.loadAll();
|
|
42
|
+
|
|
43
|
+
const pipeline = pipelineLoader.get(pipelineId);
|
|
44
|
+
if (!pipeline) {
|
|
45
|
+
throw new Error(`Pipeline "${pipelineId}" not found`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Read payload
|
|
49
|
+
let payload = {};
|
|
50
|
+
if (opts.file) {
|
|
51
|
+
const filePath = resolve(opts.file);
|
|
52
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
53
|
+
payload = JSON.parse(raw);
|
|
54
|
+
} else if (opts.data) {
|
|
55
|
+
payload = JSON.parse(opts.data);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Parse headers
|
|
59
|
+
const headers = {};
|
|
60
|
+
for (const h of opts.header) {
|
|
61
|
+
const colonIdx = h.indexOf(':');
|
|
62
|
+
if (colonIdx > 0) {
|
|
63
|
+
const key = h.slice(0, colonIdx).trim().toLowerCase();
|
|
64
|
+
const value = h.slice(colonIdx + 1).trim();
|
|
65
|
+
headers[key] = value;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const result = {
|
|
70
|
+
pipeline: pipelineId,
|
|
71
|
+
payload,
|
|
72
|
+
headers,
|
|
73
|
+
steps: {},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Auth check
|
|
77
|
+
if (pipeline.auth && !opts.skipAuth) {
|
|
78
|
+
const rawBody = JSON.stringify(payload);
|
|
79
|
+
const authResult = verifyWebhookAuth(rawBody, headers, pipeline.auth);
|
|
80
|
+
result.steps.auth = {
|
|
81
|
+
status: authResult.valid ? 'passed' : 'failed',
|
|
82
|
+
error: authResult.error || null,
|
|
83
|
+
};
|
|
84
|
+
} else if (opts.skipAuth) {
|
|
85
|
+
result.steps.auth = { status: 'skipped' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Filter check
|
|
89
|
+
if (pipeline.filter) {
|
|
90
|
+
try {
|
|
91
|
+
const filterFn = pluginLoader.loadFilter(pipeline.filter);
|
|
92
|
+
const filterResult = filterFn(payload, headers, pipeline.filter_config || {});
|
|
93
|
+
result.steps.filter = {
|
|
94
|
+
status: filterResult.pass ? 'pass' : 'drop',
|
|
95
|
+
reason: filterResult.reason || null,
|
|
96
|
+
};
|
|
97
|
+
} catch (err) {
|
|
98
|
+
result.steps.filter = { status: 'error', error: err.message };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Transform check
|
|
103
|
+
if (pipeline.transform) {
|
|
104
|
+
try {
|
|
105
|
+
const transformFn = pluginLoader.loadTransform(pipeline.transform);
|
|
106
|
+
const transformed = transformFn(payload, headers, pipeline.filter_config || {});
|
|
107
|
+
result.steps.transform = {
|
|
108
|
+
before: payload,
|
|
109
|
+
after: transformed,
|
|
110
|
+
};
|
|
111
|
+
if (transformed !== null) {
|
|
112
|
+
payload = transformed;
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
result.steps.transform = { status: 'error', error: err.message };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Destinations (dry-run preview)
|
|
120
|
+
if (pipeline.destinations) {
|
|
121
|
+
result.steps.destinations = pipeline.destinations.map((dest) => {
|
|
122
|
+
const body = (result.steps.transform && result.steps.transform.after !== undefined)
|
|
123
|
+
? result.steps.transform.after
|
|
124
|
+
: payload;
|
|
125
|
+
return {
|
|
126
|
+
url: dest.url,
|
|
127
|
+
method: (dest.method || 'POST').toUpperCase(),
|
|
128
|
+
headers: dest.headers || {},
|
|
129
|
+
bodyPreview: body != null ? JSON.stringify(body).slice(0, 500) : '(empty)',
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log(JSON.stringify(result, null, 2));
|
|
135
|
+
}
|
package/src/db/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
const MIGRATIONS_DIR = path.join(__dirname, 'migrations');
|
|
9
|
+
|
|
10
|
+
export function initDatabase(dbPath) {
|
|
11
|
+
const dir = path.dirname(dbPath);
|
|
12
|
+
if (!fs.existsSync(dir)) {
|
|
13
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const db = new Database(dbPath);
|
|
17
|
+
db.pragma('journal_mode = WAL');
|
|
18
|
+
db.pragma('foreign_keys = ON');
|
|
19
|
+
|
|
20
|
+
runMigrations(db);
|
|
21
|
+
|
|
22
|
+
return db;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function runMigrations(db) {
|
|
26
|
+
// Ensure migrations table exists
|
|
27
|
+
db.exec(`
|
|
28
|
+
CREATE TABLE IF NOT EXISTS migrations (
|
|
29
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
30
|
+
name TEXT NOT NULL UNIQUE,
|
|
31
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
32
|
+
)
|
|
33
|
+
`);
|
|
34
|
+
|
|
35
|
+
// Read migration files sorted alphabetically
|
|
36
|
+
const files = fs.readdirSync(MIGRATIONS_DIR)
|
|
37
|
+
.filter(f => f.endsWith('.sql'))
|
|
38
|
+
.sort();
|
|
39
|
+
|
|
40
|
+
const applied = new Set(
|
|
41
|
+
db.prepare('SELECT name FROM migrations').all().map(r => r.name)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
if (applied.has(file)) continue;
|
|
46
|
+
|
|
47
|
+
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf-8');
|
|
48
|
+
db.exec(sql);
|
|
49
|
+
|
|
50
|
+
// Record migration (only if not already recorded by the SQL itself)
|
|
51
|
+
const exists = db.prepare('SELECT 1 FROM migrations WHERE name = ?').get(file);
|
|
52
|
+
if (!exists) {
|
|
53
|
+
db.prepare('INSERT INTO migrations (name) VALUES (?)').run(file);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function closeDatabase(db) {
|
|
59
|
+
db.close();
|
|
60
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
-- Migration: 001_initial
|
|
2
|
+
-- Description: Create initial hookpipe schema
|
|
3
|
+
-- Date: 2024-01-01
|
|
4
|
+
|
|
5
|
+
BEGIN;
|
|
6
|
+
|
|
7
|
+
-- Migration tracking
|
|
8
|
+
CREATE TABLE IF NOT EXISTS migrations (
|
|
9
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
10
|
+
name TEXT NOT NULL UNIQUE,
|
|
11
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
-- Webhook delivery records
|
|
15
|
+
CREATE TABLE IF NOT EXISTS deliveries (
|
|
16
|
+
id TEXT PRIMARY KEY,
|
|
17
|
+
pipeline_id TEXT NOT NULL,
|
|
18
|
+
received_at TEXT NOT NULL,
|
|
19
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
20
|
+
payload TEXT NOT NULL,
|
|
21
|
+
headers TEXT NOT NULL,
|
|
22
|
+
source_ip TEXT,
|
|
23
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
24
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
-- Individual delivery attempt records
|
|
28
|
+
CREATE TABLE IF NOT EXISTS delivery_attempts (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
delivery_id TEXT NOT NULL REFERENCES deliveries(id),
|
|
31
|
+
destination_id TEXT NOT NULL,
|
|
32
|
+
attempt_number INTEGER NOT NULL,
|
|
33
|
+
status TEXT NOT NULL,
|
|
34
|
+
status_code INTEGER,
|
|
35
|
+
response_body TEXT,
|
|
36
|
+
error_message TEXT,
|
|
37
|
+
duration_ms INTEGER,
|
|
38
|
+
attempted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
39
|
+
UNIQUE(delivery_id, destination_id, attempt_number)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
-- Failed deliveries moved to DLQ
|
|
43
|
+
CREATE TABLE IF NOT EXISTS dead_letters (
|
|
44
|
+
id TEXT PRIMARY KEY,
|
|
45
|
+
delivery_id TEXT NOT NULL REFERENCES deliveries(id),
|
|
46
|
+
pipeline_id TEXT NOT NULL,
|
|
47
|
+
destination_id TEXT NOT NULL,
|
|
48
|
+
payload TEXT NOT NULL,
|
|
49
|
+
headers TEXT NOT NULL,
|
|
50
|
+
error_message TEXT,
|
|
51
|
+
attempts INTEGER NOT NULL,
|
|
52
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
-- In-process queue backing table
|
|
56
|
+
CREATE TABLE IF NOT EXISTS queue_jobs (
|
|
57
|
+
id TEXT PRIMARY KEY,
|
|
58
|
+
delivery_id TEXT NOT NULL REFERENCES deliveries(id),
|
|
59
|
+
destination_id TEXT NOT NULL,
|
|
60
|
+
pipeline_id TEXT NOT NULL,
|
|
61
|
+
payload TEXT NOT NULL,
|
|
62
|
+
headers TEXT NOT NULL,
|
|
63
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
64
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
65
|
+
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
66
|
+
next_attempt_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
67
|
+
locked_at TEXT,
|
|
68
|
+
locked_by TEXT,
|
|
69
|
+
error_message TEXT,
|
|
70
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
71
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
-- Per-pipeline statistics
|
|
75
|
+
CREATE TABLE IF NOT EXISTS pipeline_stats (
|
|
76
|
+
pipeline_id TEXT PRIMARY KEY,
|
|
77
|
+
total_received INTEGER NOT NULL DEFAULT 0,
|
|
78
|
+
total_delivered INTEGER NOT NULL DEFAULT 0,
|
|
79
|
+
total_failed INTEGER NOT NULL DEFAULT 0,
|
|
80
|
+
total_filtered INTEGER NOT NULL DEFAULT 0,
|
|
81
|
+
last_received_at TEXT,
|
|
82
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
-- Indexes: deliveries
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_deliveries_pipeline_created
|
|
87
|
+
ON deliveries(pipeline_id, created_at);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_deliveries_status
|
|
89
|
+
ON deliveries(status);
|
|
90
|
+
|
|
91
|
+
-- Indexes: delivery_attempts
|
|
92
|
+
CREATE INDEX IF NOT EXISTS idx_delivery_attempts_delivery_id
|
|
93
|
+
ON delivery_attempts(delivery_id);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_delivery_attempts_attempted_at
|
|
95
|
+
ON delivery_attempts(attempted_at);
|
|
96
|
+
|
|
97
|
+
-- Indexes: dead_letters
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_dead_letters_pipeline_created
|
|
99
|
+
ON dead_letters(pipeline_id, created_at);
|
|
100
|
+
|
|
101
|
+
-- Indexes: queue_jobs
|
|
102
|
+
CREATE INDEX IF NOT EXISTS idx_queue_jobs_status_next_attempt
|
|
103
|
+
ON queue_jobs(status, next_attempt_at);
|
|
104
|
+
CREATE INDEX IF NOT EXISTS idx_queue_jobs_delivery_id
|
|
105
|
+
ON queue_jobs(delivery_id);
|
|
106
|
+
|
|
107
|
+
COMMIT;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// --- Deliveries ---
|
|
2
|
+
|
|
3
|
+
export function insertDelivery(db, { id, pipelineId, payload, headers, sourceIp, status }) {
|
|
4
|
+
const stmt = db.prepare(`
|
|
5
|
+
INSERT INTO deliveries (id, pipeline_id, payload, headers, source_ip, status, received_at)
|
|
6
|
+
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
|
7
|
+
`);
|
|
8
|
+
stmt.run(id, pipelineId, JSON.stringify(payload), JSON.stringify(headers), sourceIp, status);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getDelivery(db, id) {
|
|
12
|
+
const row = db.prepare('SELECT * FROM deliveries WHERE id = ?').get(id);
|
|
13
|
+
if (!row) return null;
|
|
14
|
+
row.payload = JSON.parse(row.payload);
|
|
15
|
+
row.headers = JSON.parse(row.headers);
|
|
16
|
+
return row;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function listDeliveries(db, { pipelineId, status, limit = 50, offset = 0, since } = {}) {
|
|
20
|
+
const conditions = [];
|
|
21
|
+
const params = [];
|
|
22
|
+
|
|
23
|
+
if (pipelineId) {
|
|
24
|
+
conditions.push('pipeline_id = ?');
|
|
25
|
+
params.push(pipelineId);
|
|
26
|
+
}
|
|
27
|
+
if (status) {
|
|
28
|
+
conditions.push('status = ?');
|
|
29
|
+
params.push(status);
|
|
30
|
+
}
|
|
31
|
+
if (since) {
|
|
32
|
+
conditions.push('created_at >= ?');
|
|
33
|
+
params.push(since);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
37
|
+
const sql = `SELECT * FROM deliveries ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`;
|
|
38
|
+
params.push(limit, offset);
|
|
39
|
+
|
|
40
|
+
const rows = db.prepare(sql).all(...params);
|
|
41
|
+
return rows.map(row => {
|
|
42
|
+
row.payload = JSON.parse(row.payload);
|
|
43
|
+
row.headers = JSON.parse(row.headers);
|
|
44
|
+
return row;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function updateDeliveryStatus(db, id, status) {
|
|
49
|
+
db.prepare(`UPDATE deliveries SET status = ?, updated_at = datetime('now') WHERE id = ?`).run(status, id);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- Delivery Attempts ---
|
|
53
|
+
|
|
54
|
+
export function insertAttempt(db, { id, deliveryId, destinationId, attemptNumber, status, statusCode, responseBody, errorMessage, durationMs }) {
|
|
55
|
+
db.prepare(`
|
|
56
|
+
INSERT INTO delivery_attempts (id, delivery_id, destination_id, attempt_number, status, status_code, response_body, error_message, duration_ms)
|
|
57
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
58
|
+
`).run(id, deliveryId, destinationId, attemptNumber, status, statusCode, responseBody, errorMessage, durationMs);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getAttemptsByDelivery(db, deliveryId) {
|
|
62
|
+
return db.prepare('SELECT * FROM delivery_attempts WHERE delivery_id = ? ORDER BY attempt_number ASC').all(deliveryId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- Dead Letters ---
|
|
66
|
+
|
|
67
|
+
export function insertDeadLetter(db, { id, deliveryId, pipelineId, destinationId, payload, headers, errorMessage, attempts }) {
|
|
68
|
+
db.prepare(`
|
|
69
|
+
INSERT INTO dead_letters (id, delivery_id, pipeline_id, destination_id, payload, headers, error_message, attempts)
|
|
70
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
71
|
+
`).run(id, deliveryId, pipelineId, destinationId, JSON.stringify(payload), JSON.stringify(headers), errorMessage, attempts);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getDeadLetter(db, id) {
|
|
75
|
+
const row = db.prepare('SELECT * FROM dead_letters WHERE id = ?').get(id);
|
|
76
|
+
if (!row) return null;
|
|
77
|
+
row.payload = JSON.parse(row.payload);
|
|
78
|
+
row.headers = JSON.parse(row.headers);
|
|
79
|
+
return row;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function listDeadLetters(db, { pipelineId, limit = 50, offset = 0 } = {}) {
|
|
83
|
+
const conditions = [];
|
|
84
|
+
const params = [];
|
|
85
|
+
|
|
86
|
+
if (pipelineId) {
|
|
87
|
+
conditions.push('pipeline_id = ?');
|
|
88
|
+
params.push(pipelineId);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
92
|
+
const sql = `SELECT * FROM dead_letters ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`;
|
|
93
|
+
params.push(limit, offset);
|
|
94
|
+
|
|
95
|
+
const rows = db.prepare(sql).all(...params);
|
|
96
|
+
return rows.map(row => {
|
|
97
|
+
row.payload = JSON.parse(row.payload);
|
|
98
|
+
row.headers = JSON.parse(row.headers);
|
|
99
|
+
return row;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- Queue Jobs ---
|
|
104
|
+
|
|
105
|
+
export function insertQueueJob(db, { id, deliveryId, destinationId, pipelineId, payload, headers, maxAttempts }) {
|
|
106
|
+
db.prepare(`
|
|
107
|
+
INSERT INTO queue_jobs (id, delivery_id, destination_id, pipeline_id, payload, headers, max_attempts)
|
|
108
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
109
|
+
`).run(id, deliveryId, destinationId, pipelineId, JSON.stringify(payload), JSON.stringify(headers), maxAttempts);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function dequeueJobs(db, limit, lockId) {
|
|
113
|
+
const now = new Date().toISOString();
|
|
114
|
+
const dequeue = db.transaction(() => {
|
|
115
|
+
const jobs = db.prepare(`
|
|
116
|
+
SELECT * FROM queue_jobs
|
|
117
|
+
WHERE status = 'pending' AND next_attempt_at <= datetime('now')
|
|
118
|
+
LIMIT ?
|
|
119
|
+
`).all(limit);
|
|
120
|
+
|
|
121
|
+
if (jobs.length === 0) return [];
|
|
122
|
+
|
|
123
|
+
const ids = jobs.map(j => j.id);
|
|
124
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
125
|
+
db.prepare(`
|
|
126
|
+
UPDATE queue_jobs
|
|
127
|
+
SET status = 'processing', locked_at = datetime('now'), locked_by = ?, updated_at = datetime('now')
|
|
128
|
+
WHERE id IN (${placeholders})
|
|
129
|
+
`).run(lockId, ...ids);
|
|
130
|
+
|
|
131
|
+
return db.prepare(`
|
|
132
|
+
SELECT * FROM queue_jobs WHERE id IN (${placeholders})
|
|
133
|
+
`).all(...ids).map(row => {
|
|
134
|
+
row.payload = JSON.parse(row.payload);
|
|
135
|
+
row.headers = JSON.parse(row.headers);
|
|
136
|
+
return row;
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
return dequeue();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function ackJob(db, jobId) {
|
|
143
|
+
db.prepare(`UPDATE queue_jobs SET status = 'completed', updated_at = datetime('now') WHERE id = ?`).run(jobId);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function nackJob(db, jobId, errorMessage, nextAttemptAt) {
|
|
147
|
+
db.prepare(`
|
|
148
|
+
UPDATE queue_jobs
|
|
149
|
+
SET status = 'pending',
|
|
150
|
+
attempts = attempts + 1,
|
|
151
|
+
error_message = ?,
|
|
152
|
+
next_attempt_at = ?,
|
|
153
|
+
locked_at = NULL,
|
|
154
|
+
locked_by = NULL,
|
|
155
|
+
updated_at = datetime('now')
|
|
156
|
+
WHERE id = ?
|
|
157
|
+
`).run(errorMessage, nextAttemptAt, jobId);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function moveJobToDeadLetter(db, jobId) {
|
|
161
|
+
db.prepare(`UPDATE queue_jobs SET status = 'dead_letter', updated_at = datetime('now') WHERE id = ?`).run(jobId);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function getJobsByDelivery(db, deliveryId) {
|
|
165
|
+
return db.prepare('SELECT * FROM queue_jobs WHERE delivery_id = ? ORDER BY created_at ASC').all(deliveryId).map(row => {
|
|
166
|
+
row.payload = JSON.parse(row.payload);
|
|
167
|
+
row.headers = JSON.parse(row.headers);
|
|
168
|
+
return row;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- Pipeline Stats ---
|
|
173
|
+
|
|
174
|
+
export function incrementStat(db, pipelineId, field) {
|
|
175
|
+
const validFields = ['total_received', 'total_delivered', 'total_failed', 'total_filtered'];
|
|
176
|
+
if (!validFields.includes(field)) {
|
|
177
|
+
throw new Error(`Invalid stat field: ${field}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const lastReceivedClause = field === 'total_received'
|
|
181
|
+
? ", last_received_at = datetime('now')"
|
|
182
|
+
: '';
|
|
183
|
+
|
|
184
|
+
db.prepare(`
|
|
185
|
+
INSERT INTO pipeline_stats (pipeline_id, ${field}, updated_at${field === 'total_received' ? ', last_received_at' : ''})
|
|
186
|
+
VALUES (?, 1, datetime('now')${field === 'total_received' ? ", datetime('now')" : ''})
|
|
187
|
+
ON CONFLICT(pipeline_id) DO UPDATE SET
|
|
188
|
+
${field} = ${field} + 1,
|
|
189
|
+
updated_at = datetime('now')
|
|
190
|
+
${lastReceivedClause}
|
|
191
|
+
`).run(pipelineId);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function getStats(db, pipelineId) {
|
|
195
|
+
return db.prepare('SELECT * FROM pipeline_stats WHERE pipeline_id = ?').get(pipelineId) || null;
|
|
196
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const DEFAULTS = {
|
|
2
|
+
backoff: 'exponential',
|
|
3
|
+
initialDelayMs: 1000,
|
|
4
|
+
maxDelayMs: 300000,
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Calculate the delay before the next retry attempt.
|
|
9
|
+
* @param {number} attempt - Current attempt number (1-based)
|
|
10
|
+
* @param {object} config - Retry configuration
|
|
11
|
+
* @returns {number} Delay in milliseconds (integer)
|
|
12
|
+
*/
|
|
13
|
+
export function calculateNextDelay(attempt, config = {}) {
|
|
14
|
+
const backoff = config.backoff || DEFAULTS.backoff;
|
|
15
|
+
const initialDelayMs = config.initialDelayMs ?? DEFAULTS.initialDelayMs;
|
|
16
|
+
const maxDelayMs = config.maxDelayMs ?? DEFAULTS.maxDelayMs;
|
|
17
|
+
|
|
18
|
+
let baseDelay;
|
|
19
|
+
|
|
20
|
+
switch (backoff) {
|
|
21
|
+
case 'linear':
|
|
22
|
+
baseDelay = Math.min(initialDelayMs * attempt, maxDelayMs);
|
|
23
|
+
return baseDelay + jitter(baseDelay);
|
|
24
|
+
|
|
25
|
+
case 'fixed':
|
|
26
|
+
return initialDelayMs;
|
|
27
|
+
|
|
28
|
+
case 'exponential':
|
|
29
|
+
default:
|
|
30
|
+
baseDelay = Math.min(initialDelayMs * 2 ** (attempt - 1), maxDelayMs);
|
|
31
|
+
return baseDelay + jitter(baseDelay);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Determine if another retry should be attempted.
|
|
37
|
+
* @param {number} attempt - Current attempt number
|
|
38
|
+
* @param {number} maxAttempts - Maximum allowed attempts
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
export function shouldRetry(attempt, maxAttempts) {
|
|
42
|
+
return attempt < maxAttempts;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get the ISO 8601 timestamp for when the next attempt should occur.
|
|
47
|
+
* @param {number} attempt - Current attempt number
|
|
48
|
+
* @param {object} config - Retry configuration
|
|
49
|
+
* @returns {string} ISO 8601 date string
|
|
50
|
+
*/
|
|
51
|
+
export function getNextAttemptTime(attempt, config) {
|
|
52
|
+
const delay = calculateNextDelay(attempt, config);
|
|
53
|
+
return new Date(Date.now() + delay).toISOString();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Add random jitter of 0-25% of the base delay.
|
|
58
|
+
* @param {number} baseDelay
|
|
59
|
+
* @returns {number} Integer jitter value
|
|
60
|
+
*/
|
|
61
|
+
function jitter(baseDelay) {
|
|
62
|
+
return Math.floor(Math.random() * baseDelay * 0.25);
|
|
63
|
+
}
|