@usehenri/webhooks 0.0.0 → 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,230 @@
1
+ const { coded } = require('../errors');
2
+
3
+ /**
4
+ * The table `@usehenri/webhooks` owns, and the DDL of every SQL dialect
5
+ * henri can talk to.
6
+ *
7
+ * Endpoints are henri's data, not the application's: a url, a set of
8
+ * events, signing secrets and whether the thing is still enabled. They are
9
+ * never a henri model, for the reason the queue is not one either -- an
10
+ * application would then have to carry a migration for a table it does not
11
+ * own, and a store with no models at all would have no endpoints. So this
12
+ * is one table reached through the adapter's `query()`, or one MongoDB
13
+ * collection, exactly like `henri_jobs`.
14
+ *
15
+ * There is no deliveries table, and that is deliberate: a delivery is a job
16
+ * in `henri_jobs`, so what succeeded, what is waiting, what is dead and why
17
+ * is already answered by `henri jobs:list`, `henri jobs:dead` and
18
+ * `henri jobs:show`. A second table would be a second, worse copy of it.
19
+ *
20
+ * Every moment is a BIGINT of milliseconds since the epoch, as in the
21
+ * queue: sqlite has no date type and the other three disagree on the
22
+ * precision and the time zone of a bare `TIMESTAMP`.
23
+ */
24
+
25
+ /** Table names an application may give */
26
+ const SAFE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
27
+
28
+ const DIALECTS = {
29
+ mssql: {
30
+ /**
31
+ * Wraps a statement so it only runs when the index is missing
32
+ *
33
+ * @param {string} table The table name
34
+ * @param {string} index The index name
35
+ * @param {string} statement The CREATE INDEX statement
36
+ * @returns {string} The guarded statement
37
+ */
38
+ guardIndex: (table, index, statement) =>
39
+ `IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = '${index}' AND object_id = OBJECT_ID('${table}')) ${statement}`,
40
+
41
+ /**
42
+ * Wraps a CREATE TABLE so it only runs when the table is missing
43
+ *
44
+ * @param {string} table The table name
45
+ * @param {string} statement The CREATE TABLE statement
46
+ * @returns {string} The guarded statement
47
+ */
48
+ guardTable: (table, statement) =>
49
+ `IF OBJECT_ID('${table}', 'U') IS NULL ${statement}`,
50
+
51
+ ifNotExists: '',
52
+ inlineIndexes: false,
53
+ quote: (identifier) => `[${identifier}]`,
54
+ text: 'NVARCHAR(MAX)',
55
+ url: 'NVARCHAR(2048)',
56
+ },
57
+ mysql: {
58
+ ifNotExists: 'IF NOT EXISTS',
59
+ // MySQL has no CREATE INDEX IF NOT EXISTS: the indexes are declared in
60
+ // the CREATE TABLE, which is guarded
61
+ inlineIndexes: true,
62
+ quote: (identifier) => `\`${identifier}\``,
63
+ text: 'MEDIUMTEXT',
64
+ url: 'VARCHAR(2048)',
65
+ },
66
+ postgres: {
67
+ ifNotExists: 'IF NOT EXISTS',
68
+ inlineIndexes: false,
69
+ quote: (identifier) => `"${identifier}"`,
70
+ text: 'TEXT',
71
+ url: 'VARCHAR(2048)',
72
+ },
73
+ sqlite: {
74
+ ifNotExists: 'IF NOT EXISTS',
75
+ inlineIndexes: false,
76
+ quote: (identifier) => `"${identifier}"`,
77
+ text: 'TEXT',
78
+ url: 'VARCHAR(2048)',
79
+ },
80
+ };
81
+
82
+ /** The columns of the endpoints table, in order */
83
+ const COLUMNS = [
84
+ 'id',
85
+ 'owner',
86
+ 'url',
87
+ 'events',
88
+ 'secrets',
89
+ 'headers',
90
+ 'description',
91
+ 'disabled_at',
92
+ 'disabled_reason',
93
+ 'created_at',
94
+ 'updated_at',
95
+ ];
96
+
97
+ /**
98
+ * The column definitions of the endpoints table
99
+ *
100
+ * @param {object} dialect A dialect description
101
+ * @returns {Array<string>} The definitions
102
+ */
103
+ const columns = (dialect) => [
104
+ 'id VARCHAR(36) NOT NULL',
105
+ 'owner VARCHAR(190) NULL',
106
+ `url ${dialect.url} NOT NULL`,
107
+ `events ${dialect.text} NOT NULL`,
108
+ `secrets ${dialect.text} NOT NULL`,
109
+ `headers ${dialect.text} NULL`,
110
+ 'description VARCHAR(190) NULL',
111
+ 'disabled_at BIGINT NULL',
112
+ 'disabled_reason VARCHAR(190) NULL',
113
+ 'created_at BIGINT NOT NULL',
114
+ 'updated_at BIGINT NOT NULL',
115
+ 'PRIMARY KEY (id)',
116
+ ];
117
+
118
+ /**
119
+ * The indexes of the endpoints table
120
+ *
121
+ * One index, on what a lookup filters by: the tenant an event belongs to
122
+ * and whether the endpoint still takes deliveries. Which events an endpoint
123
+ * subscribes to is a JSON list, matched in this process, because no two of
124
+ * these four dialects agree on how to ask that question.
125
+ *
126
+ * @param {string} table The table name
127
+ * @returns {Array<object>} `{ name, columns }` entries
128
+ */
129
+ const indexes = (table) => [
130
+ { columns: ['owner', 'disabled_at'], name: `${table}_owner` },
131
+ ];
132
+
133
+ /**
134
+ * Every statement `henri webhooks:install` runs, in order
135
+ *
136
+ * All of them are idempotent: running the install twice, or against a
137
+ * database another process already prepared, changes nothing.
138
+ *
139
+ * @param {string} name The dialect (sqlite, postgres, mysql, mssql)
140
+ * @param {object} tables `{ endpoints }` table names
141
+ * @returns {Array<string>} The statements
142
+ * @throws {Error} When the dialect or the table name is unknown
143
+ */
144
+ const install = (name, tables) => {
145
+ const dialect = DIALECTS[name];
146
+
147
+ if (!dialect) {
148
+ throw coded(
149
+ 'HENRI_WEBHOOK_UNSUPPORTED_STORE',
150
+ `@usehenri/webhooks: unsupported SQL dialect "${name}"`
151
+ );
152
+ }
153
+
154
+ if (!SAFE_NAME.test(tables.endpoints)) {
155
+ throw coded(
156
+ 'HENRI_CONFIG_INVALID',
157
+ `@usehenri/webhooks: invalid table name "${tables.endpoints}": letters, digits and underscores only`
158
+ );
159
+ }
160
+
161
+ const table = tables.endpoints;
162
+ const quoted = dialect.quote(table);
163
+ const definitions = [...columns(dialect)];
164
+ const statements = [];
165
+
166
+ if (dialect.inlineIndexes) {
167
+ for (const index of indexes(table)) {
168
+ definitions.push(
169
+ `KEY ${dialect.quote(index.name)} (${index.columns.join(', ')})`
170
+ );
171
+ }
172
+ }
173
+
174
+ const create = [
175
+ 'CREATE TABLE',
176
+ dialect.ifNotExists,
177
+ `${quoted} (\n ${definitions.join(',\n ')}\n)`,
178
+ ]
179
+ .filter(Boolean)
180
+ .join(' ');
181
+
182
+ statements.push(
183
+ dialect.guardTable ? dialect.guardTable(table, create) : create
184
+ );
185
+
186
+ if (dialect.inlineIndexes) {
187
+ return statements;
188
+ }
189
+
190
+ for (const index of indexes(table)) {
191
+ const statement = [
192
+ 'CREATE INDEX',
193
+ dialect.guardIndex ? '' : dialect.ifNotExists,
194
+ `${dialect.quote(index.name)} ON ${quoted} (${index.columns.join(', ')})`,
195
+ ]
196
+ .filter(Boolean)
197
+ .join(' ');
198
+
199
+ statements.push(
200
+ dialect.guardIndex
201
+ ? dialect.guardIndex(table, index.name, statement)
202
+ : statement
203
+ );
204
+ }
205
+
206
+ return statements;
207
+ };
208
+
209
+ /**
210
+ * The statement that drops the table
211
+ *
212
+ * @param {string} name The dialect
213
+ * @param {object} tables `{ endpoints }` table names
214
+ * @returns {Array<string>} The statements
215
+ * @throws {Error} When the dialect is unknown
216
+ */
217
+ const uninstall = (name, tables) => {
218
+ const dialect = DIALECTS[name];
219
+
220
+ if (!dialect) {
221
+ throw coded(
222
+ 'HENRI_WEBHOOK_UNSUPPORTED_STORE',
223
+ `@usehenri/webhooks: unsupported SQL dialect "${name}"`
224
+ );
225
+ }
226
+
227
+ return [`DROP TABLE IF EXISTS ${dialect.quote(tables.endpoints)}`];
228
+ };
229
+
230
+ module.exports = { COLUMNS, DIALECTS, columns, indexes, install, uninstall };
@@ -0,0 +1,450 @@
1
+ const debug = require('debug')('henri:webhooks:sql');
2
+
3
+ const { COLUMNS, install, uninstall } = require('./schema');
4
+ const { WebhookError } = require('../errors');
5
+
6
+ /**
7
+ * The SQL backend of the endpoints table.
8
+ *
9
+ * Everything goes through the store adapter's own `query()`, so no henri
10
+ * model is involved and an application whose store has no models at all
11
+ * still has endpoints. There is no claiming and no contention here -- an
12
+ * endpoint is written by an operator and read by the runners -- so this is
13
+ * six plain statements, and the concurrency of the queue is not repeated.
14
+ */
15
+
16
+ /**
17
+ * Errors that mean the object was created by someone else in between
18
+ *
19
+ * `CREATE TABLE IF NOT EXISTS` is not atomic against a concurrent creation
20
+ * on PostgreSQL: two processes booting together can both find the table
21
+ * missing, and one of them then fails on the catalogue's own unique index.
22
+ * The install is idempotent by intent, so that failure means it is done.
23
+ */
24
+ const ALREADY_THERE =
25
+ /already exists|duplicate key|duplicate table|there is already an object named/iu;
26
+
27
+ /** Errors that mean "another writer got there first, try again" */
28
+ const RETRYABLE =
29
+ /deadlock|lock wait timeout|database is locked|database table is locked|SQLITE_BUSY/iu;
30
+
31
+ /**
32
+ * Everything an error says about itself, wrappers included
33
+ *
34
+ * Sequelize keeps the driver error on `parent`, drizzle on `cause`.
35
+ *
36
+ * @param {*} error An error
37
+ * @param {number} [depth=4] How far to unwrap
38
+ * @returns {string} The messages, joined
39
+ */
40
+ const reasons = (error, depth = 4) => {
41
+ const said = [];
42
+ let current = error;
43
+
44
+ for (let step = 0; step < depth && current; step += 1) {
45
+ said.push(String(current.message || ''), String(current.code || ''));
46
+ current = current.parent || current.original || current.cause;
47
+ }
48
+
49
+ return said.join(' ');
50
+ };
51
+
52
+ /**
53
+ * A number read back from any driver (pg hands BIGINT over as a string)
54
+ *
55
+ * @param {*} value The stored value
56
+ * @returns {?number} The number, or null
57
+ */
58
+ const toNumber = (value) => {
59
+ if (value === null || typeof value === 'undefined' || value === '') {
60
+ return null;
61
+ }
62
+
63
+ const number = Number(value);
64
+
65
+ return Number.isNaN(number) ? null : number;
66
+ };
67
+
68
+ /**
69
+ * The `?` placeholders of a list
70
+ *
71
+ * @param {Array} values The values
72
+ * @returns {string} `?, ?, ?`
73
+ */
74
+ const marks = (values) => values.map(() => '?').join(', ');
75
+
76
+ /**
77
+ * The SQL store
78
+ *
79
+ * @class SqlStore
80
+ */
81
+ class SqlStore {
82
+ /**
83
+ * Creates an instance of SqlStore.
84
+ *
85
+ * @param {object} adapter A henri store adapter with `query()`
86
+ * @param {object} options Options
87
+ * @param {string} options.dialect sqlite, postgres, mysql or mssql
88
+ * @param {boolean} [options.dollars=false] The driver numbers its
89
+ * placeholders (`$1`), as node-postgres does
90
+ * @param {object} options.tables `{ endpoints }` table names
91
+ * @memberof SqlStore
92
+ */
93
+ constructor(adapter, { dialect, dollars = false, tables }) {
94
+ this.adapter = adapter;
95
+ this.dialect = dialect;
96
+ this.dollars = dollars;
97
+ this.tables = tables;
98
+ this.kind = 'sql';
99
+ }
100
+
101
+ /**
102
+ * The statement with the placeholders the driver expects
103
+ *
104
+ * @param {string} sql A statement written with `?` placeholders
105
+ * @returns {string} The statement
106
+ * @memberof SqlStore
107
+ */
108
+ prepare(sql) {
109
+ if (!this.dollars) {
110
+ return sql;
111
+ }
112
+
113
+ let index = 0;
114
+
115
+ return sql.replace(/\?/gu, () => {
116
+ index += 1;
117
+
118
+ return `$${index}`;
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Runs an operation again when the database says another writer won
124
+ *
125
+ * @param {Function} fn The operation
126
+ * @param {number} [attempts=8] How many times to try
127
+ * @returns {Promise<*>} What fn returns
128
+ * @throws {Error} The last error when every attempt failed
129
+ * @memberof SqlStore
130
+ */
131
+ async retrying(fn, attempts = 8) {
132
+ let last = null;
133
+
134
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
135
+ try {
136
+ return await fn();
137
+ } catch (error) {
138
+ if (!RETRYABLE.test(reasons(error))) {
139
+ throw error;
140
+ }
141
+
142
+ last = error;
143
+ await new Promise((resolve) => setTimeout(resolve, 15 * (attempt + 1)));
144
+ }
145
+ }
146
+
147
+ throw last;
148
+ }
149
+
150
+ /**
151
+ * Runs a statement that returns no rows
152
+ *
153
+ * @param {string} sql The statement, with `?` placeholders
154
+ * @param {Array} [params=[]] The parameters
155
+ * @returns {Promise<void>} Resolves when done
156
+ * @memberof SqlStore
157
+ */
158
+ async run(sql, params = []) {
159
+ debug('run %s', sql);
160
+
161
+ await this.retrying(() => this.adapter.query(this.prepare(sql), params));
162
+ }
163
+
164
+ /**
165
+ * Runs a query and returns its rows
166
+ *
167
+ * @param {string} sql The query, with `?` placeholders
168
+ * @param {Array} [params=[]] The parameters
169
+ * @returns {Promise<Array<object>>} The rows
170
+ * @memberof SqlStore
171
+ */
172
+ async select(sql, params = []) {
173
+ debug('select %s', sql);
174
+
175
+ const result = await this.retrying(() =>
176
+ this.adapter.query(this.prepare(sql), params, { type: 'SELECT' })
177
+ );
178
+
179
+ return Array.isArray(result) ? result : [];
180
+ }
181
+
182
+ /**
183
+ * Creates the table and its index; idempotent
184
+ *
185
+ * @returns {Promise<Array<string>>} The statements that ran
186
+ * @memberof SqlStore
187
+ */
188
+ async install() {
189
+ const statements = install(this.dialect, this.tables);
190
+
191
+ for (const statement of statements) {
192
+ try {
193
+ await this.run(statement);
194
+ } catch (error) {
195
+ if (!ALREADY_THERE.test(reasons(error))) {
196
+ throw error;
197
+ }
198
+
199
+ debug('another process created it first: %s', error.message);
200
+ }
201
+ }
202
+
203
+ return statements;
204
+ }
205
+
206
+ /**
207
+ * Drops the table
208
+ *
209
+ * @returns {Promise<Array<string>>} The statements that ran
210
+ * @memberof SqlStore
211
+ */
212
+ async uninstall() {
213
+ const statements = uninstall(this.dialect, this.tables);
214
+
215
+ for (const statement of statements) {
216
+ await this.run(statement);
217
+ }
218
+
219
+ return statements;
220
+ }
221
+
222
+ /**
223
+ * Whether the table is there
224
+ *
225
+ * @returns {Promise<boolean>} true when it answers
226
+ * @memberof SqlStore
227
+ */
228
+ async installed() {
229
+ try {
230
+ await this.select(
231
+ `SELECT COUNT(*) AS total FROM ${this.tables.endpoints}`
232
+ );
233
+
234
+ return true;
235
+ } catch (error) {
236
+ return false;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Inserts an endpoint
242
+ *
243
+ * @param {object} row A row, in database shape
244
+ * @returns {Promise<object>} The endpoint, read back
245
+ * @memberof SqlStore
246
+ */
247
+ async insert(row) {
248
+ const values = COLUMNS.map((column) =>
249
+ typeof row[column] === 'undefined' ? null : row[column]
250
+ );
251
+
252
+ await this.run(
253
+ `INSERT INTO ${this.tables.endpoints} (${COLUMNS.join(', ')}) VALUES (${marks(COLUMNS)})`,
254
+ values
255
+ );
256
+
257
+ return this.find(row.id);
258
+ }
259
+
260
+ /**
261
+ * One endpoint by id
262
+ *
263
+ * @param {string} id The endpoint id
264
+ * @returns {Promise<?object>} The row, or null
265
+ * @memberof SqlStore
266
+ */
267
+ async find(id) {
268
+ const [row] = await this.select(
269
+ `SELECT * FROM ${this.tables.endpoints} WHERE id = ?`,
270
+ [id]
271
+ );
272
+
273
+ return row || null;
274
+ }
275
+
276
+ /**
277
+ * Writes a few columns of an endpoint
278
+ *
279
+ * @param {string} id The endpoint id
280
+ * @param {object} changes The columns to write
281
+ * @returns {Promise<?object>} The row, read back
282
+ * @memberof SqlStore
283
+ */
284
+ async update(id, changes) {
285
+ const keys = Object.keys(changes).filter((key) => COLUMNS.includes(key));
286
+
287
+ if (keys.length === 0) {
288
+ return this.find(id);
289
+ }
290
+
291
+ await this.run(
292
+ `UPDATE ${this.tables.endpoints} SET ${keys
293
+ .map((key) => `${key} = ?`)
294
+ .join(', ')} WHERE id = ?`,
295
+ [...keys.map((key) => changes[key]), id]
296
+ );
297
+
298
+ return this.find(id);
299
+ }
300
+
301
+ /**
302
+ * Deletes an endpoint
303
+ *
304
+ * @param {string} id The endpoint id
305
+ * @returns {Promise<boolean>} Whether there was one to delete
306
+ * @memberof SqlStore
307
+ */
308
+ async remove(id) {
309
+ const before = await this.find(id);
310
+
311
+ await this.run(`DELETE FROM ${this.tables.endpoints} WHERE id = ?`, [id]);
312
+
313
+ return Boolean(before);
314
+ }
315
+
316
+ /**
317
+ * The endpoints of an application, or of one owner
318
+ *
319
+ * @param {object} [filter={}] `owner`, `disabled`, `limit`, `offset`
320
+ * @returns {Promise<Array<object>>} The rows
321
+ * @memberof SqlStore
322
+ */
323
+ async list(filter = {}) {
324
+ const where = [];
325
+ const params = [];
326
+
327
+ if (typeof filter.owner === 'string') {
328
+ where.push('owner = ?');
329
+ params.push(filter.owner);
330
+ }
331
+
332
+ // `null` is a filter, not the absence of one: an event emitted without
333
+ // an owner reaches the endpoints that have none, and never a tenant's
334
+ if (filter.owner === null) {
335
+ where.push('owner IS NULL');
336
+ }
337
+
338
+ if (filter.disabled === false) {
339
+ where.push('disabled_at IS NULL');
340
+ }
341
+
342
+ if (filter.disabled === true) {
343
+ where.push('disabled_at IS NOT NULL');
344
+ }
345
+
346
+ const limit = Math.max(1, Math.min(Number(filter.limit) || 1000, 10000));
347
+ const offset = Math.max(0, Number(filter.offset) || 0);
348
+ const clause = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '';
349
+ const paging =
350
+ this.dialect === 'mssql'
351
+ ? ` OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY`
352
+ : ` LIMIT ${limit} OFFSET ${offset}`;
353
+
354
+ return this.select(
355
+ `SELECT * FROM ${this.tables.endpoints}${clause} ORDER BY created_at ASC, id ASC${paging}`,
356
+ params
357
+ );
358
+ }
359
+
360
+ /**
361
+ * How many endpoints there are
362
+ *
363
+ * @param {object} [filter={}] `owner`, `disabled`
364
+ * @returns {Promise<number>} The count
365
+ * @memberof SqlStore
366
+ */
367
+ async count(filter = {}) {
368
+ const where = [];
369
+ const params = [];
370
+
371
+ if (typeof filter.owner === 'string') {
372
+ where.push('owner = ?');
373
+ params.push(filter.owner);
374
+ }
375
+
376
+ if (filter.owner === null) {
377
+ where.push('owner IS NULL');
378
+ }
379
+
380
+ if (filter.disabled === false) {
381
+ where.push('disabled_at IS NULL');
382
+ }
383
+
384
+ if (filter.disabled === true) {
385
+ where.push('disabled_at IS NOT NULL');
386
+ }
387
+
388
+ const clause = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '';
389
+ const [row] = await this.select(
390
+ `SELECT COUNT(*) AS total FROM ${this.tables.endpoints}${clause}`,
391
+ params
392
+ );
393
+
394
+ return toNumber(row && (row.total || row.TOTAL)) || 0;
395
+ }
396
+ }
397
+
398
+ /**
399
+ * The dialect of a store adapter, or nothing when it is not SQL
400
+ *
401
+ * @param {object} adapter A henri store adapter
402
+ * @returns {?object} `{ dialect, dollars }`
403
+ */
404
+ const describe = (adapter) => {
405
+ // The drizzle adapter names its dialect and its placeholder style
406
+ if (adapter.dialect && typeof adapter.dialect === 'object') {
407
+ return {
408
+ dialect: adapter.dialect.name,
409
+ dollars: adapter.dialect.placeholder(1) === '$1',
410
+ };
411
+ }
412
+
413
+ // The sequelize adapters: the dialect comes from the connector, and
414
+ // sequelize renders `?` replacements itself on every dialect
415
+ if (typeof adapter.ensureConnector === 'function') {
416
+ return { dialect: adapter.ensureConnector().getDialect(), dollars: false };
417
+ }
418
+
419
+ return null;
420
+ };
421
+
422
+ /**
423
+ * Builds the SQL store of an adapter
424
+ *
425
+ * @param {object} adapter A henri store adapter
426
+ * @param {object} tables `{ endpoints }` table names
427
+ * @returns {SqlStore} The store
428
+ * @throws {WebhookError} When the dialect cannot hold the endpoints
429
+ */
430
+ const create = (adapter, tables) => {
431
+ const described = describe(adapter);
432
+
433
+ if (!described) {
434
+ throw new WebhookError(
435
+ 'HENRI_WEBHOOK_UNSUPPORTED_STORE',
436
+ `@usehenri/webhooks: the ${adapter.adapterName} adapter has no SQL surface`
437
+ );
438
+ }
439
+
440
+ if (!['mssql', 'mysql', 'postgres', 'sqlite'].includes(described.dialect)) {
441
+ throw new WebhookError(
442
+ 'HENRI_WEBHOOK_UNSUPPORTED_STORE',
443
+ `@usehenri/webhooks: the ${described.dialect} dialect is not supported`
444
+ );
445
+ }
446
+
447
+ return new SqlStore(adapter, { ...described, tables });
448
+ };
449
+
450
+ module.exports = { SqlStore, create, describe, reasons, toNumber };