webhookgate-sdk 1.0.0 → 1.0.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.
Files changed (3) hide show
  1. package/idempotent.js +142 -0
  2. package/index.js +1 -1
  3. package/package.json +1 -1
package/idempotent.js ADDED
@@ -0,0 +1,142 @@
1
+ import { Pool } from "pg";
2
+
3
+ const pool = new Pool({
4
+ host: process.env.PGHOST,
5
+ port: process.env.PGPORT ? Number(process.env.PGPORT) : undefined,
6
+ database: process.env.PGDATABASE,
7
+ user: process.env.PGUSER,
8
+ password: process.env.PGPASSWORD,
9
+ ssl: process.env.PGSSL === "true" ? { rejectUnauthorized: false } : undefined,
10
+ });
11
+
12
+ let _readyPromise = null;
13
+
14
+ async function ensureTables() {
15
+ if (_readyPromise) return _readyPromise;
16
+
17
+ _readyPromise = (async () => {
18
+ await pool.query(`
19
+ CREATE TABLE IF NOT EXISTS webhook_idempotency (
20
+ idempotency_key TEXT PRIMARY KEY,
21
+ status TEXT NOT NULL CHECK (status IN ('processing','done','failed')),
22
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
23
+ completed_at TIMESTAMPTZ
24
+ );
25
+ `);
26
+
27
+ await pool.query(`
28
+ CREATE TABLE IF NOT EXISTS demo_charges (
29
+ id BIGSERIAL PRIMARY KEY,
30
+ idempotency_key TEXT NOT NULL UNIQUE,
31
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
32
+ );
33
+ `);
34
+ })();
35
+
36
+ return _readyPromise;
37
+ }
38
+
39
+ function getIdempotencyKey(req) {
40
+ // Primary: what our gateway sends
41
+ const k = req.get("Idempotency-Key") || req.get("idempotency-key");
42
+ return k ? String(k).trim() : "";
43
+ }
44
+
45
+ /**
46
+ * Locked API:
47
+ * app.post("/webhooks/stripe", idempotent(async ({ req, event, db }) => {}))
48
+ *
49
+ * Guarantees:
50
+ * - first time key seen: handler runs
51
+ * - any repeat of same key: handler does NOT run (returns 200)
52
+ * - crash or error mid-handler: key transitions to 'failed' and is never re-run
53
+ */
54
+ export function idempotent(handler) {
55
+ return async function idempotentMiddleware(req, res) {
56
+ await ensureTables();
57
+
58
+ const key = getIdempotencyKey(req);
59
+
60
+ if (!key) {
61
+ return res.status(400).json({
62
+ ok: false,
63
+ error: "Missing Idempotency-Key header",
64
+ });
65
+ }
66
+
67
+ // IMPORTANT: the SDK assumes req.body already contains the parsed event
68
+ const event = req.body;
69
+
70
+ const client = await pool.connect();
71
+ let inTxn = false;
72
+ try {
73
+ // Atomic "claim"
74
+ // If it inserts: we own execution.
75
+ // If it conflicts: someone already claimed (or finished) => skip.
76
+ const claimed = await client.query(
77
+ `
78
+ INSERT INTO webhook_idempotency (idempotency_key, status)
79
+ VALUES ($1, 'processing')
80
+ ON CONFLICT (idempotency_key) DO NOTHING
81
+ RETURNING idempotency_key
82
+ `,
83
+ [key]
84
+ );
85
+
86
+ if (claimed.rowCount === 0) {
87
+ // Already seen => do nothing (exactly-once effects)
88
+ return res.status(200).json({ ok: true, deduped: true });
89
+ }
90
+
91
+ // 2) Transaction boundary for DB effects inside handler
92
+ await client.query("BEGIN");
93
+ inTxn = true;
94
+
95
+ // Run user handler
96
+ await handler({ req, res, event, db: client });
97
+
98
+ // Mark done
99
+ await client.query(
100
+ `
101
+ UPDATE webhook_idempotency
102
+ SET status='done', completed_at=now()
103
+ WHERE idempotency_key=$1
104
+ `,
105
+ [key]
106
+ );
107
+
108
+ await client.query("COMMIT");
109
+ inTxn = false;
110
+
111
+ // If handler already wrote to res, don't double-send.
112
+ if (!res.headersSent) {
113
+ return res.status(200).json({ ok: true, deduped: false });
114
+ }
115
+ } catch (err) {
116
+ // best-effort rollback if handler started a txn
117
+ try {
118
+ if (inTxn) await client.query("ROLLBACK");
119
+ } catch (_) {}
120
+
121
+ // Optional: mark failed (still prevents reruns, which is the contract you chose)
122
+ try {
123
+ await client.query(
124
+ `
125
+ UPDATE webhook_idempotency
126
+ SET status='failed', completed_at=now()
127
+ WHERE idempotency_key=$1 AND status='processing'
128
+ `,
129
+ [key]
130
+ );
131
+ } catch (_) {
132
+ // ignore secondary failure
133
+ }
134
+
135
+ if (!res.headersSent) {
136
+ return res.status(500).json({ ok: false, error: err?.message || String(err) });
137
+ }
138
+ } finally {
139
+ client.release();
140
+ }
141
+ };
142
+ }
package/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // packages/sdk/index.js
2
- export { idempotent } from "../../src/idempotent.js";
2
+ export { idempotent } from "./idempotent.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhookgate-sdk",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "exports": {