verdaccio-stats 0.1.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 +67 -0
- package/lib/config.d.ts +40 -0
- package/lib/constants.d.ts +11 -0
- package/lib/debugger.d.ts +4 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +683 -0
- package/lib/index.mjs +679 -0
- package/lib/logger.d.ts +4 -0
- package/lib/middlewares/admin-ui.d.ts +17 -0
- package/lib/middlewares/hooks.d.ts +13 -0
- package/lib/middlewares/stats.d.ts +16 -0
- package/lib/migrations.d.ts +3 -0
- package/lib/models.d.ts +23 -0
- package/lib/plugin.d.ts +12 -0
- package/lib/storage/db.d.ts +19 -0
- package/lib/types.d.ts +7 -0
- package/lib/utils.d.ts +33 -0
- package/package.json +76 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var z = require('zod');
|
|
6
|
+
var dayjs = require('dayjs');
|
|
7
|
+
var advancedFormat = require('dayjs/plugin/advancedFormat');
|
|
8
|
+
var isoWeek = require('dayjs/plugin/isoWeek');
|
|
9
|
+
var weekOfYear = require('dayjs/plugin/weekOfYear');
|
|
10
|
+
var weekYear = require('dayjs/plugin/weekYear');
|
|
11
|
+
var path = require('node:path');
|
|
12
|
+
var sequelize = require('sequelize');
|
|
13
|
+
var core = require('@verdaccio/core');
|
|
14
|
+
var middleware = require('@verdaccio/middleware');
|
|
15
|
+
var buildDebug = require('debug');
|
|
16
|
+
var umzug = require('umzug');
|
|
17
|
+
|
|
18
|
+
var name = "verdaccio-stats";
|
|
19
|
+
var version = "0.1.0";
|
|
20
|
+
|
|
21
|
+
const plugin = {
|
|
22
|
+
name,
|
|
23
|
+
version,
|
|
24
|
+
};
|
|
25
|
+
const pluginKey = name.replace("verdaccio-", "");
|
|
26
|
+
const UNIVERSE_PACKAGE_NAME = "**";
|
|
27
|
+
const UNIVERSE_PACKAGE_VERSION = "*";
|
|
28
|
+
const PERIOD_TYPES = ["overall", "daily", "monthly", "weekly", "yearly"];
|
|
29
|
+
const PERIOD_VALUE_TOTAL = "total";
|
|
30
|
+
const DEFAULT_SQLITE_FILE = "stats.db";
|
|
31
|
+
const API_BASE_PATH = "/-/verdaccio/stats";
|
|
32
|
+
|
|
33
|
+
function noop() {
|
|
34
|
+
/* noop */
|
|
35
|
+
}
|
|
36
|
+
const dummyLogger = {
|
|
37
|
+
child: () => dummyLogger,
|
|
38
|
+
debug: noop,
|
|
39
|
+
error: noop,
|
|
40
|
+
http: noop,
|
|
41
|
+
info: noop,
|
|
42
|
+
trace: noop,
|
|
43
|
+
warn: noop,
|
|
44
|
+
};
|
|
45
|
+
let logger = dummyLogger;
|
|
46
|
+
function setLogger(l) {
|
|
47
|
+
if (!l)
|
|
48
|
+
return;
|
|
49
|
+
logger = l.child({ plugin: { name: plugin.name } });
|
|
50
|
+
logger.info(plugin, "plugin loading: @{name}@@{version}");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
dayjs.extend(advancedFormat);
|
|
54
|
+
dayjs.extend(weekYear);
|
|
55
|
+
dayjs.extend(weekOfYear);
|
|
56
|
+
dayjs.extend(isoWeek);
|
|
57
|
+
/**
|
|
58
|
+
* Get the period value for the given period type.
|
|
59
|
+
*
|
|
60
|
+
* @param periodType {PeriodType} - The period type.
|
|
61
|
+
* @param date {Dayjs} - The date to get the period value for.
|
|
62
|
+
* @param isoWeek {boolean} - Whether to use ISO week format.
|
|
63
|
+
* @returns {PeriodValue} The period value.
|
|
64
|
+
*/
|
|
65
|
+
function getPeriodValue(periodType, date, isoWeek) {
|
|
66
|
+
switch (periodType) {
|
|
67
|
+
case "daily": {
|
|
68
|
+
return dayjs(date).format("YYYY-MM-DD");
|
|
69
|
+
}
|
|
70
|
+
case "monthly": {
|
|
71
|
+
return dayjs(date).format("YYYY-MM");
|
|
72
|
+
}
|
|
73
|
+
case "overall": {
|
|
74
|
+
return PERIOD_VALUE_TOTAL;
|
|
75
|
+
}
|
|
76
|
+
case "weekly": {
|
|
77
|
+
return dayjs(date).format(isoWeek ? "GGGG-[W]WW" : "gggg-[W]ww");
|
|
78
|
+
}
|
|
79
|
+
case "yearly": {
|
|
80
|
+
return dayjs(date).format("YYYY");
|
|
81
|
+
}
|
|
82
|
+
default: {
|
|
83
|
+
throw new Error(`Unknown period type: ${String(periodType)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Check if the status code is a success status code.
|
|
89
|
+
*
|
|
90
|
+
* @param statusCode {number} - The status code.
|
|
91
|
+
* @returns {boolean} Whether the status code is a success status code.
|
|
92
|
+
*/
|
|
93
|
+
function isSuccessStatus(statusCode) {
|
|
94
|
+
return (statusCode >= 200 && statusCode < 300) || statusCode === 304;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Get the absolute path of a file path.
|
|
98
|
+
*
|
|
99
|
+
* @param configPath {string} - The path to the config file.
|
|
100
|
+
* @param targetPath {string} - The path to the files.
|
|
101
|
+
* @returns {string} The absolute path of the file.
|
|
102
|
+
*/
|
|
103
|
+
function normalizeFilePath(configPath, targetPath) {
|
|
104
|
+
return path.isAbsolute(targetPath) ? targetPath : path.normalize(path.join(path.dirname(configPath), targetPath));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Wraps the given URL path for Verdaccio stats.
|
|
108
|
+
*
|
|
109
|
+
* @param urlPath {string} - The path to be wrapped.
|
|
110
|
+
* @returns {string} The wrapped path.
|
|
111
|
+
*/
|
|
112
|
+
function wrapPath(urlPath) {
|
|
113
|
+
return `${API_BASE_PATH}${urlPath}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const statsConfig = z.object({
|
|
117
|
+
file: z.string().optional(),
|
|
118
|
+
"iso-week": z.boolean().optional(),
|
|
119
|
+
"count-downloads": z.boolean().optional(),
|
|
120
|
+
"count-manifest-views": z.boolean().optional(),
|
|
121
|
+
});
|
|
122
|
+
class ParsedPluginConfig {
|
|
123
|
+
config;
|
|
124
|
+
verdaccioConfig;
|
|
125
|
+
get configPath() {
|
|
126
|
+
return this.verdaccioConfig.configPath ?? this.verdaccioConfig.self_path;
|
|
127
|
+
}
|
|
128
|
+
get countDownloads() {
|
|
129
|
+
return this.config["count-downloads"] ?? true;
|
|
130
|
+
}
|
|
131
|
+
get countManifestViews() {
|
|
132
|
+
return this.config["count-manifest-views"] ?? true;
|
|
133
|
+
}
|
|
134
|
+
get file() {
|
|
135
|
+
return normalizeFilePath(this.configPath, this.config.file ?? DEFAULT_SQLITE_FILE);
|
|
136
|
+
}
|
|
137
|
+
get isoWeek() {
|
|
138
|
+
return this.config["iso-week"] ?? false;
|
|
139
|
+
}
|
|
140
|
+
get logo() {
|
|
141
|
+
return this.verdaccioConfig.web?.logo;
|
|
142
|
+
}
|
|
143
|
+
get title() {
|
|
144
|
+
return this.verdaccioConfig.web?.title ?? "Verdaccio Stats";
|
|
145
|
+
}
|
|
146
|
+
constructor(config, verdaccioConfig) {
|
|
147
|
+
this.config = config;
|
|
148
|
+
this.verdaccioConfig = verdaccioConfig;
|
|
149
|
+
try {
|
|
150
|
+
statsConfig.parse(config);
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
const zodError = err;
|
|
154
|
+
logger.error({ errors: zodError.flatten().fieldErrors }, "Invalid config, @{errors}");
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
class DownloadStats extends sequelize.Model {
|
|
161
|
+
}
|
|
162
|
+
class ManifestViewStats extends sequelize.Model {
|
|
163
|
+
}
|
|
164
|
+
class Package extends sequelize.Model {
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const rootPath = wrapPath("/admin");
|
|
168
|
+
const defaultActions = {
|
|
169
|
+
new: { isAccessible: false },
|
|
170
|
+
edit: { isAccessible: false },
|
|
171
|
+
delete: { isAccessible: false },
|
|
172
|
+
bulkDelete: { isAccessible: false },
|
|
173
|
+
};
|
|
174
|
+
process.env.ADMIN_JS_SKIP_BUNDLE = "true";
|
|
175
|
+
/**
|
|
176
|
+
* Add Admin UI to the application.
|
|
177
|
+
*/
|
|
178
|
+
class AdminUI {
|
|
179
|
+
adminRouter = null;
|
|
180
|
+
config;
|
|
181
|
+
constructor(config) {
|
|
182
|
+
this.config = config;
|
|
183
|
+
void this.create().then((router) => {
|
|
184
|
+
this.adminRouter = router;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
static populatePackageIdListProperties(response) {
|
|
188
|
+
for (const record of response.records) {
|
|
189
|
+
if (record.populated.packageId?.params) {
|
|
190
|
+
const params = record.populated.packageId.params;
|
|
191
|
+
record.populated.packageId.title = `${params.name}@${params.version}`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return response;
|
|
195
|
+
}
|
|
196
|
+
static populatePackageIdSearchProperties(response) {
|
|
197
|
+
for (const record of response.records) {
|
|
198
|
+
if (record.populated.packageId?.params) {
|
|
199
|
+
const params = record.populated.packageId.params;
|
|
200
|
+
record.populated.packageId.title = `${params.name}@${params.version}`;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return response;
|
|
204
|
+
}
|
|
205
|
+
static populatePackageIdShowProperties(response) {
|
|
206
|
+
if (response.record.populated.packageId?.params) {
|
|
207
|
+
const params = response.record.populated.packageId.params;
|
|
208
|
+
response.record.populated.packageId.title = `${params.name}@${params.version}`;
|
|
209
|
+
}
|
|
210
|
+
return response;
|
|
211
|
+
}
|
|
212
|
+
register_middlewares(app) {
|
|
213
|
+
app.use(rootPath, (req, res, next) => {
|
|
214
|
+
if (this.adminRouter) {
|
|
215
|
+
return this.adminRouter(req, res, next);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
res.status(503).send("Admin UI is not ready yet.");
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
async create() {
|
|
223
|
+
const [AdminJS, AdminJSExpress, AdminJSSequelize] = await Promise.all([
|
|
224
|
+
import('adminjs').then((mod) => mod.default),
|
|
225
|
+
import('@adminjs/express').then((mod) => mod.default),
|
|
226
|
+
import('@adminjs/sequelize').then((mod) => mod.default),
|
|
227
|
+
]);
|
|
228
|
+
AdminJS.registerAdapter({
|
|
229
|
+
Resource: AdminJSSequelize.Resource,
|
|
230
|
+
Database: AdminJSSequelize.Database,
|
|
231
|
+
});
|
|
232
|
+
const admin = new AdminJS({
|
|
233
|
+
resources: [
|
|
234
|
+
{
|
|
235
|
+
resource: Package,
|
|
236
|
+
options: {
|
|
237
|
+
actions: { ...defaultActions },
|
|
238
|
+
listProperties: ["id", "name", "version", "createdAt"],
|
|
239
|
+
showProperties: ["id", "name", "version", "createdAt"],
|
|
240
|
+
filterProperties: ["name", "version"],
|
|
241
|
+
sort: { sortBy: "createdAt", direction: "desc" },
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
resource: DownloadStats,
|
|
246
|
+
options: {
|
|
247
|
+
actions: {
|
|
248
|
+
...defaultActions,
|
|
249
|
+
show: { after: AdminUI.populatePackageIdShowProperties },
|
|
250
|
+
list: { after: AdminUI.populatePackageIdListProperties },
|
|
251
|
+
search: { after: AdminUI.populatePackageIdSearchProperties },
|
|
252
|
+
},
|
|
253
|
+
properties: {
|
|
254
|
+
periodType: {
|
|
255
|
+
availableValues: PERIOD_TYPES.map((type) => ({ value: type, label: type })),
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
listProperties: ["id", "packageId", "periodType", "periodValue", "count", "createdAt", "updatedAt"],
|
|
259
|
+
showProperties: ["id", "packageId", "periodType", "periodValue", "count", "createdAt", "updatedAt"],
|
|
260
|
+
filterProperties: ["packageId", "periodType", "periodValue", "createdAt", "updatedAt"],
|
|
261
|
+
sort: { sortBy: "updatedAt", direction: "desc" },
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
resource: ManifestViewStats,
|
|
266
|
+
options: {
|
|
267
|
+
actions: {
|
|
268
|
+
...defaultActions,
|
|
269
|
+
show: { after: AdminUI.populatePackageIdShowProperties },
|
|
270
|
+
list: { after: AdminUI.populatePackageIdListProperties },
|
|
271
|
+
search: { after: AdminUI.populatePackageIdSearchProperties },
|
|
272
|
+
},
|
|
273
|
+
properties: {
|
|
274
|
+
periodType: {
|
|
275
|
+
availableValues: PERIOD_TYPES.map((type) => ({ value: type, label: type })),
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
listProperties: ["id", "packageId", "periodType", "periodValue", "count", "createdAt", "updatedAt"],
|
|
279
|
+
showProperties: ["id", "packageId", "periodType", "periodValue", "count", "createdAt", "updatedAt"],
|
|
280
|
+
filterProperties: ["packageId", "periodType", "periodValue", "createdAt", "updatedAt"],
|
|
281
|
+
sort: { sortBy: "updatedAt", direction: "desc" },
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
rootPath: rootPath,
|
|
286
|
+
branding: {
|
|
287
|
+
companyName: this.config.title,
|
|
288
|
+
logo: this.config.logo,
|
|
289
|
+
},
|
|
290
|
+
env: {
|
|
291
|
+
ADMIN_JS_SKIP_BUNDLE: "true",
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
return AdminJSExpress.buildRouter(admin);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
class Hooks {
|
|
299
|
+
config;
|
|
300
|
+
db = null;
|
|
301
|
+
constructor(config) {
|
|
302
|
+
this.config = config;
|
|
303
|
+
}
|
|
304
|
+
packageManifestHandler = (req, res, next) => {
|
|
305
|
+
const db = this.db;
|
|
306
|
+
if (!db) {
|
|
307
|
+
logger.warn("DB instance is not ready; skipping manifest stats");
|
|
308
|
+
return next();
|
|
309
|
+
}
|
|
310
|
+
const packageName = req.params.package;
|
|
311
|
+
const version = req.params.version;
|
|
312
|
+
if (packageName === "favicon.ico") {
|
|
313
|
+
logger.debug("Skipping manifest stats for favicon request");
|
|
314
|
+
return next();
|
|
315
|
+
}
|
|
316
|
+
res.once("finish", () => {
|
|
317
|
+
if (!isSuccessStatus(res.statusCode)) {
|
|
318
|
+
logger.debug("Skipping manifest stats for non-2xx response");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (!packageName) {
|
|
322
|
+
logger.warn("Unexpected missing package name in request");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
logger.debug({ packageName, version }, "Adding manifest view stats for package @{packageName} version @{version}");
|
|
326
|
+
db.addManifestViewCount(packageName, version).catch((err) => {
|
|
327
|
+
logger.error({ err }, "Failed to add manifest count; @{err}");
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
return next();
|
|
331
|
+
};
|
|
332
|
+
register_middlewares(app) {
|
|
333
|
+
if (this.config.countDownloads) {
|
|
334
|
+
app.get(middleware.PACKAGE_API_ENDPOINTS.get_package_tarball, this.tarballDownloadHandler);
|
|
335
|
+
}
|
|
336
|
+
if (this.config.countManifestViews) {
|
|
337
|
+
app.get(middleware.PACKAGE_API_ENDPOINTS.get_package_by_version, this.packageManifestHandler);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
setDatabase(db) {
|
|
341
|
+
this.db = db;
|
|
342
|
+
}
|
|
343
|
+
tarballDownloadHandler = (req, res, next) => {
|
|
344
|
+
const db = this.db;
|
|
345
|
+
if (!db) {
|
|
346
|
+
logger.warn("DB instance is not ready; skipping download stats");
|
|
347
|
+
return next();
|
|
348
|
+
}
|
|
349
|
+
// react
|
|
350
|
+
const packageName = req.params.package;
|
|
351
|
+
// react-18.0.0.tgz
|
|
352
|
+
const filename = req.params.filename;
|
|
353
|
+
res.once("finish", () => {
|
|
354
|
+
if (!isSuccessStatus(res.statusCode)) {
|
|
355
|
+
logger.debug("Skipping download stats for non-2xx response");
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// react-18.0.0.tgz -> 18.0.0
|
|
359
|
+
const version = core.tarballUtils.getVersionFromTarball(filename);
|
|
360
|
+
if (!packageName || !version) {
|
|
361
|
+
logger.warn("Unexpected missing package name or filename in request");
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
logger.debug({ packageName, version }, "Adding download stats for package @{packageName} version @{version}");
|
|
365
|
+
db.addDownloadCount(packageName, version).catch((err) => {
|
|
366
|
+
logger.error({ err }, "Failed to add download count; @{err}");
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
return next();
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* NOTE: This middleware is not implemented yet.
|
|
375
|
+
*/
|
|
376
|
+
class Stats {
|
|
377
|
+
config;
|
|
378
|
+
db = null;
|
|
379
|
+
constructor(config) {
|
|
380
|
+
this.config = config;
|
|
381
|
+
}
|
|
382
|
+
register_middlewares(app) {
|
|
383
|
+
app.get(wrapPath("/downloads/latest"), this.notImplementedHandler);
|
|
384
|
+
app.get(wrapPath("/downloads/total"), this.notImplementedHandler);
|
|
385
|
+
app.get(wrapPath("/downloads/popular/:count?"), this.notImplementedHandler);
|
|
386
|
+
app.get(wrapPath("/downloads/package/:package/:version?"), this.notImplementedHandler);
|
|
387
|
+
app.get(wrapPath("/manifest/latest"), this.notImplementedHandler);
|
|
388
|
+
app.get(wrapPath("/manifest/total"), this.notImplementedHandler);
|
|
389
|
+
app.get(wrapPath("/manifest/popular/:count?"), this.notImplementedHandler);
|
|
390
|
+
app.get(wrapPath("/manifest/views/package/:package/:version?"), this.notImplementedHandler);
|
|
391
|
+
}
|
|
392
|
+
setDatabase(db) {
|
|
393
|
+
this.db = db;
|
|
394
|
+
}
|
|
395
|
+
checkDatabase(db, res) {
|
|
396
|
+
if (!db) {
|
|
397
|
+
res.sendStatus(500).send("Database not set");
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
notImplementedHandler = (req, res) => {
|
|
403
|
+
if (!this.checkDatabase(this.db, res))
|
|
404
|
+
return;
|
|
405
|
+
res.sendStatus(501).send("Not implemented");
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const debug = buildDebug(`verdaccio:plugin:${pluginKey}`);
|
|
410
|
+
function getUmzugLogger() {
|
|
411
|
+
return {
|
|
412
|
+
debug: (msg) => debug("umzug:debug: %j", msg),
|
|
413
|
+
error: (msg) => debug("umzug:error: %j", msg),
|
|
414
|
+
info: (msg) => debug("umzug:info: %j", msg),
|
|
415
|
+
warn: (msg) => debug("umzug:warn: %j", msg),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const migrations = [
|
|
420
|
+
{
|
|
421
|
+
name: "000-initial",
|
|
422
|
+
up: async ({ context: sequelize$1 }) => {
|
|
423
|
+
const queryInterface = sequelize$1.getQueryInterface();
|
|
424
|
+
await Promise.all([
|
|
425
|
+
queryInterface.createTable("packages", {
|
|
426
|
+
id: { allowNull: false, autoIncrement: true, primaryKey: true, type: sequelize.DataTypes.INTEGER },
|
|
427
|
+
name: { allowNull: false, type: sequelize.DataTypes.STRING(100) },
|
|
428
|
+
version: { allowNull: false, type: sequelize.DataTypes.STRING(50) },
|
|
429
|
+
created_at: { allowNull: false, type: sequelize.DataTypes.DATE },
|
|
430
|
+
updated_at: { allowNull: false, type: sequelize.DataTypes.DATE },
|
|
431
|
+
}),
|
|
432
|
+
queryInterface.createTable("download_stats", {
|
|
433
|
+
id: { allowNull: false, autoIncrement: true, primaryKey: true, type: sequelize.DataTypes.INTEGER },
|
|
434
|
+
package_id: { allowNull: false, type: sequelize.DataTypes.INTEGER },
|
|
435
|
+
period_type: { allowNull: false, type: sequelize.DataTypes.STRING(20) },
|
|
436
|
+
period_value: { allowNull: false, type: sequelize.DataTypes.STRING(20) },
|
|
437
|
+
count: { allowNull: false, type: sequelize.DataTypes.BIGINT, defaultValue: 0 },
|
|
438
|
+
created_at: { allowNull: false, type: sequelize.DataTypes.DATE },
|
|
439
|
+
updated_at: { allowNull: false, type: sequelize.DataTypes.DATE },
|
|
440
|
+
}),
|
|
441
|
+
queryInterface.createTable("manifest_view_stats", {
|
|
442
|
+
id: { allowNull: false, autoIncrement: true, primaryKey: true, type: sequelize.DataTypes.INTEGER },
|
|
443
|
+
package_id: { allowNull: false, type: sequelize.DataTypes.INTEGER },
|
|
444
|
+
period_type: { allowNull: false, type: sequelize.DataTypes.STRING(20) },
|
|
445
|
+
period_value: { allowNull: false, type: sequelize.DataTypes.STRING(20) },
|
|
446
|
+
count: { allowNull: false, type: sequelize.DataTypes.BIGINT, defaultValue: 0 },
|
|
447
|
+
created_at: { allowNull: false, type: sequelize.DataTypes.DATE },
|
|
448
|
+
updated_at: { allowNull: false, type: sequelize.DataTypes.DATE },
|
|
449
|
+
}),
|
|
450
|
+
]);
|
|
451
|
+
await Promise.all([
|
|
452
|
+
queryInterface.addIndex("packages", ["name", "version"], {
|
|
453
|
+
unique: true,
|
|
454
|
+
name: "packages_index",
|
|
455
|
+
}),
|
|
456
|
+
queryInterface.addIndex("download_stats", ["package_id", "period_type", "period_value"], {
|
|
457
|
+
name: "download_stats_index",
|
|
458
|
+
}),
|
|
459
|
+
queryInterface.addIndex("manifest_view_stats", ["package_id", "period_type", "period_value"], {
|
|
460
|
+
name: "manifest_view_stats_index",
|
|
461
|
+
}),
|
|
462
|
+
]);
|
|
463
|
+
return queryInterface.bulkInsert("packages", [
|
|
464
|
+
{
|
|
465
|
+
name: UNIVERSE_PACKAGE_NAME,
|
|
466
|
+
version: UNIVERSE_PACKAGE_VERSION,
|
|
467
|
+
created_at: new Date(),
|
|
468
|
+
updated_at: new Date(),
|
|
469
|
+
},
|
|
470
|
+
]);
|
|
471
|
+
},
|
|
472
|
+
down: async ({ context: Sequelize }) => {
|
|
473
|
+
const queryInterface = Sequelize.getQueryInterface();
|
|
474
|
+
await Promise.all([
|
|
475
|
+
queryInterface.removeIndex("packages", "packages_index"),
|
|
476
|
+
queryInterface.removeIndex("download_stats", "download_stats_index"),
|
|
477
|
+
queryInterface.removeIndex("manifest_view_stats", "manifest_view_stats_index"),
|
|
478
|
+
]);
|
|
479
|
+
return Promise.all(["packages", "download_stats", "manifest_view_stats"].map((table) => queryInterface.dropTable(table)));
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
];
|
|
483
|
+
|
|
484
|
+
class Database {
|
|
485
|
+
config;
|
|
486
|
+
sequelize;
|
|
487
|
+
umzug;
|
|
488
|
+
constructor(config) {
|
|
489
|
+
const sequelize$1 = new sequelize.Sequelize({
|
|
490
|
+
dialect: "sqlite",
|
|
491
|
+
storage: config.file,
|
|
492
|
+
logging: (sql) => buildDebug(sql),
|
|
493
|
+
});
|
|
494
|
+
const umzug$1 = new umzug.Umzug({
|
|
495
|
+
context: () => sequelize$1,
|
|
496
|
+
migrations: migrations,
|
|
497
|
+
storage: new umzug.SequelizeStorage({ sequelize: sequelize$1, modelName: "migration_meta" }),
|
|
498
|
+
logger: getUmzugLogger(),
|
|
499
|
+
});
|
|
500
|
+
this.config = config;
|
|
501
|
+
this.sequelize = sequelize$1;
|
|
502
|
+
this.umzug = umzug$1;
|
|
503
|
+
}
|
|
504
|
+
static async create(config) {
|
|
505
|
+
const db = new Database(config);
|
|
506
|
+
await db.migrate();
|
|
507
|
+
await db.init();
|
|
508
|
+
return db;
|
|
509
|
+
}
|
|
510
|
+
async addDownloadCount(packageName, version) {
|
|
511
|
+
const t = await this.sequelize.transaction();
|
|
512
|
+
try {
|
|
513
|
+
await Promise.all([
|
|
514
|
+
this.addTotalDownloadCount(t),
|
|
515
|
+
this.addPackageDownloadCount(packageName, UNIVERSE_PACKAGE_VERSION, t),
|
|
516
|
+
this.addPackageDownloadCount(packageName, version, t),
|
|
517
|
+
]);
|
|
518
|
+
await t.commit();
|
|
519
|
+
}
|
|
520
|
+
catch (err) {
|
|
521
|
+
await t.rollback();
|
|
522
|
+
throw err;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
async addManifestViewCount(packageName, version) {
|
|
526
|
+
const t = await this.sequelize.transaction();
|
|
527
|
+
try {
|
|
528
|
+
await Promise.all([
|
|
529
|
+
this.addTotalManifestViewCount(t),
|
|
530
|
+
this.addPackageManifestViewCount(packageName, UNIVERSE_PACKAGE_VERSION, t),
|
|
531
|
+
version && this.addPackageManifestViewCount(packageName, version, t),
|
|
532
|
+
].filter(Boolean));
|
|
533
|
+
await t.commit();
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
await t.rollback();
|
|
537
|
+
throw err;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
migrate() {
|
|
541
|
+
return this.umzug.up();
|
|
542
|
+
}
|
|
543
|
+
rollback() {
|
|
544
|
+
return this.umzug.down();
|
|
545
|
+
}
|
|
546
|
+
async addDownloadForAllPeriod(pkg, transaction) {
|
|
547
|
+
return Promise.all(PERIOD_TYPES.map(async (periodType) => {
|
|
548
|
+
const [downloadStats] = await DownloadStats.findOrCreate({
|
|
549
|
+
where: {
|
|
550
|
+
packageId: pkg.id,
|
|
551
|
+
periodType: periodType,
|
|
552
|
+
periodValue: getPeriodValue(periodType, undefined, this.config.isoWeek),
|
|
553
|
+
},
|
|
554
|
+
transaction,
|
|
555
|
+
});
|
|
556
|
+
return downloadStats.increment("count", { by: 1, transaction });
|
|
557
|
+
}));
|
|
558
|
+
}
|
|
559
|
+
async addManifestViewForAllPeriod(pkg, transaction) {
|
|
560
|
+
return Promise.all(PERIOD_TYPES.map(async (periodType) => {
|
|
561
|
+
const [manifestViewStats] = await ManifestViewStats.findOrCreate({
|
|
562
|
+
where: {
|
|
563
|
+
packageId: pkg.id,
|
|
564
|
+
periodType: periodType,
|
|
565
|
+
periodValue: getPeriodValue(periodType, undefined, this.config.isoWeek),
|
|
566
|
+
},
|
|
567
|
+
transaction,
|
|
568
|
+
});
|
|
569
|
+
return manifestViewStats.increment("count", { by: 1, transaction });
|
|
570
|
+
}));
|
|
571
|
+
}
|
|
572
|
+
async addPackageDownloadCount(packageName, version, transaction) {
|
|
573
|
+
const [pkg] = await Package.findOrCreate({ where: { name: packageName, version }, transaction });
|
|
574
|
+
if (!pkg) {
|
|
575
|
+
throw new Error("Package not found");
|
|
576
|
+
}
|
|
577
|
+
return this.addDownloadForAllPeriod(pkg, transaction);
|
|
578
|
+
}
|
|
579
|
+
async addPackageManifestViewCount(packageName, version, transaction) {
|
|
580
|
+
const [pkg] = await Package.findOrCreate({ where: { name: packageName, version }, transaction });
|
|
581
|
+
if (!pkg) {
|
|
582
|
+
throw new Error("Package not found");
|
|
583
|
+
}
|
|
584
|
+
return this.addManifestViewForAllPeriod(pkg, transaction);
|
|
585
|
+
}
|
|
586
|
+
async addTotalDownloadCount(transaction) {
|
|
587
|
+
const universePkg = await Package.findOne({
|
|
588
|
+
where: { name: UNIVERSE_PACKAGE_NAME, version: UNIVERSE_PACKAGE_VERSION },
|
|
589
|
+
transaction,
|
|
590
|
+
});
|
|
591
|
+
if (!universePkg) {
|
|
592
|
+
throw new Error("Universe package not found");
|
|
593
|
+
}
|
|
594
|
+
return this.addDownloadForAllPeriod(universePkg, transaction);
|
|
595
|
+
}
|
|
596
|
+
async addTotalManifestViewCount(transaction) {
|
|
597
|
+
const universePkg = await Package.findOne({
|
|
598
|
+
where: { name: UNIVERSE_PACKAGE_NAME, version: UNIVERSE_PACKAGE_VERSION },
|
|
599
|
+
transaction,
|
|
600
|
+
});
|
|
601
|
+
if (!universePkg) {
|
|
602
|
+
throw new Error("Universe package not found");
|
|
603
|
+
}
|
|
604
|
+
return this.addManifestViewForAllPeriod(universePkg, transaction);
|
|
605
|
+
}
|
|
606
|
+
async init() {
|
|
607
|
+
await Promise.all([
|
|
608
|
+
Package.init({
|
|
609
|
+
id: { allowNull: false, autoIncrement: true, primaryKey: true, type: sequelize.DataTypes.INTEGER },
|
|
610
|
+
name: { allowNull: false, type: sequelize.DataTypes.STRING(100) },
|
|
611
|
+
version: { allowNull: false, type: sequelize.DataTypes.STRING(50) },
|
|
612
|
+
}, { sequelize: this.sequelize, tableName: "packages", underscored: true }),
|
|
613
|
+
DownloadStats.init({
|
|
614
|
+
count: { allowNull: false, type: sequelize.DataTypes.BIGINT, defaultValue: 0 },
|
|
615
|
+
id: { allowNull: false, autoIncrement: true, primaryKey: true, type: sequelize.DataTypes.INTEGER },
|
|
616
|
+
packageId: { allowNull: false, type: sequelize.DataTypes.INTEGER, references: { model: Package, key: "id" } },
|
|
617
|
+
periodType: { allowNull: false, type: sequelize.DataTypes.STRING(20) },
|
|
618
|
+
periodValue: { allowNull: false, type: sequelize.DataTypes.STRING(20) },
|
|
619
|
+
}, { sequelize: this.sequelize, tableName: "download_stats", underscored: true }),
|
|
620
|
+
ManifestViewStats.init({
|
|
621
|
+
count: { allowNull: false, type: sequelize.DataTypes.BIGINT, defaultValue: 0 },
|
|
622
|
+
id: { allowNull: false, autoIncrement: true, primaryKey: true, type: sequelize.DataTypes.INTEGER },
|
|
623
|
+
packageId: { allowNull: false, type: sequelize.DataTypes.INTEGER, references: { model: Package, key: "id" } },
|
|
624
|
+
periodType: { allowNull: false, type: sequelize.DataTypes.STRING(20) },
|
|
625
|
+
periodValue: { allowNull: false, type: sequelize.DataTypes.STRING(20) },
|
|
626
|
+
}, { sequelize: this.sequelize, tableName: "manifest_view_stats", underscored: true }),
|
|
627
|
+
]);
|
|
628
|
+
Package.hasMany(DownloadStats, {
|
|
629
|
+
sourceKey: "id",
|
|
630
|
+
foreignKey: "packageId",
|
|
631
|
+
});
|
|
632
|
+
Package.hasMany(ManifestViewStats, {
|
|
633
|
+
sourceKey: "id",
|
|
634
|
+
foreignKey: "packageId",
|
|
635
|
+
});
|
|
636
|
+
DownloadStats.belongsTo(Package, {
|
|
637
|
+
targetKey: "id",
|
|
638
|
+
foreignKey: "packageId",
|
|
639
|
+
as: "package",
|
|
640
|
+
});
|
|
641
|
+
ManifestViewStats.belongsTo(Package, {
|
|
642
|
+
targetKey: "id",
|
|
643
|
+
foreignKey: "packageId",
|
|
644
|
+
as: "package",
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
class Plugin {
|
|
650
|
+
config;
|
|
651
|
+
options;
|
|
652
|
+
get version() {
|
|
653
|
+
return +plugin.version;
|
|
654
|
+
}
|
|
655
|
+
parsedConfig;
|
|
656
|
+
constructor(config, options) {
|
|
657
|
+
this.config = config;
|
|
658
|
+
this.options = options;
|
|
659
|
+
setLogger(options.logger);
|
|
660
|
+
this.parsedConfig = new ParsedPluginConfig(config, options.config);
|
|
661
|
+
}
|
|
662
|
+
getVersion() {
|
|
663
|
+
return this.version;
|
|
664
|
+
}
|
|
665
|
+
register_middlewares(app) {
|
|
666
|
+
const db = Database.create(this.parsedConfig);
|
|
667
|
+
const hooks = new Hooks(this.parsedConfig);
|
|
668
|
+
const stats = new Stats(this.parsedConfig);
|
|
669
|
+
const adminUI = new AdminUI(this.parsedConfig);
|
|
670
|
+
db.then((db) => {
|
|
671
|
+
hooks.setDatabase(db);
|
|
672
|
+
stats.setDatabase(db);
|
|
673
|
+
}).catch((err) => {
|
|
674
|
+
logger.error({ err }, "Failed to initialize database; @{err}");
|
|
675
|
+
process.exit(1);
|
|
676
|
+
});
|
|
677
|
+
for (const middleware of [hooks, stats, adminUI]) {
|
|
678
|
+
middleware.register_middlewares(app);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
exports.default = Plugin;
|