@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.
- package/CHANGELOG.md +115 -0
- package/LICENSE +21 -0
- package/README.md +8 -1
- package/index.js +48 -0
- package/module.js +8 -0
- package/package.json +51 -10
- package/src/address.js +280 -0
- package/src/config.js +85 -0
- package/src/deliver.js +271 -0
- package/src/errors.js +107 -0
- package/src/job.js +49 -0
- package/src/module.js +368 -0
- package/src/secrets.js +240 -0
- package/src/signature.js +330 -0
- package/src/store/index.js +41 -0
- package/src/store/mongo.js +229 -0
- package/src/store/schema.js +230 -0
- package/src/store/sql.js +450 -0
- package/src/webhooks.js +1219 -0
|
@@ -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 };
|
package/src/store/sql.js
ADDED
|
@@ -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 };
|