power-queues 2.1.6 → 2.1.8
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/README.md +84 -13
- package/dist/index.cjs +29 -27
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +29 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,26 +35,99 @@ yarn add power-queues
|
|
|
35
35
|
|
|
36
36
|
``` ts
|
|
37
37
|
const queue = new PowerQueues({
|
|
38
|
-
stream: '
|
|
38
|
+
stream: 'mysql',
|
|
39
39
|
group: 'workers',
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
await queue.loadScripts(true);
|
|
43
43
|
|
|
44
|
-
await queue.addTasks('
|
|
45
|
-
{
|
|
46
|
-
{
|
|
44
|
+
await queue.addTasks('mysql_create:example:table_name', [
|
|
45
|
+
{ type: 'welcome', userId: 42 },
|
|
46
|
+
{ type: 'hello', userId: 51 }
|
|
47
47
|
]);
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
Example of a worker for executing a MySQL insert transaction:
|
|
51
51
|
|
|
52
52
|
``` ts
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
import mysql from 'mysql2/promise';
|
|
54
|
+
import Redis from 'ioredis';
|
|
55
|
+
import type { IORedisLike } from 'power-redis';
|
|
56
|
+
import { type Task, PowerQueues, } from 'power-queues';
|
|
57
|
+
import {
|
|
58
|
+
isArrFilled,
|
|
59
|
+
isObjFilled,
|
|
60
|
+
} from 'full-utils';
|
|
61
|
+
|
|
62
|
+
const pool = mysql.createPool({
|
|
63
|
+
host: '127.0.0.1',
|
|
64
|
+
port: 3306,
|
|
65
|
+
user: 'user',
|
|
66
|
+
password: 'password',
|
|
67
|
+
database: 'example',
|
|
68
|
+
waitForConnections: true,
|
|
69
|
+
connectionLimit: 10,
|
|
70
|
+
});
|
|
71
|
+
const redis = new Redis('redis://127.0.0.1:6379');
|
|
72
|
+
|
|
73
|
+
export class ExampleQueue extends PowerQueues {
|
|
74
|
+
public readonly selectStuckCount: number = 256;
|
|
75
|
+
public readonly selectCount: number = 256;
|
|
76
|
+
public readonly retryCount: number = 3;
|
|
77
|
+
public readonly executeSync: boolean = true;
|
|
78
|
+
public readonly removeOnExecuted: boolean = true;
|
|
79
|
+
public redis!: IORedisLike;
|
|
80
|
+
|
|
81
|
+
constructor() {
|
|
82
|
+
super();
|
|
83
|
+
|
|
84
|
+
this.redis = redis;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async onBatchReady(queueName: string, tasks: Task[]) {
|
|
88
|
+
const values = tasks
|
|
89
|
+
.filter((task) => isObjFilled(task.payload))
|
|
90
|
+
.map((task) => task.payload);
|
|
91
|
+
|
|
92
|
+
if (isArrFilled(values)) {
|
|
93
|
+
const conn = await pool.getConnection();
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await conn.beginTransaction();
|
|
97
|
+
|
|
98
|
+
const cols = Object.keys(values[0]);
|
|
99
|
+
const placeholder = `(${cols.map(() => '?').join(',')})`;
|
|
100
|
+
const sql = `INSERT INTO \`alerts\` (${cols.map((c) => `\`${c}\``).join(',')}) VALUES ${values.map(() => placeholder).join(',')}`;
|
|
101
|
+
const params = [];
|
|
102
|
+
|
|
103
|
+
for (const row of values) {
|
|
104
|
+
for (const c of cols) {
|
|
105
|
+
params.push(row[c]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
await conn.execute(sql, params);
|
|
109
|
+
await conn.commit();
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
await conn.rollback();
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
conn.release();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async onBatchError(err: any, queueName: string, tasks: Array<[ string, any, number, string, string, number ]>) {
|
|
122
|
+
this.logger.error('Transaction error', queueName, tasks.length, (process.env.NODE_ENV === 'production')
|
|
123
|
+
? err.message
|
|
124
|
+
: err, tasks.map((task) => task[1]));
|
|
56
125
|
}
|
|
57
126
|
}
|
|
127
|
+
|
|
128
|
+
const exampleQueue = new ExampleQueue();
|
|
129
|
+
|
|
130
|
+
exampleQueue.runQueue('mysql_create:example:table_name');
|
|
58
131
|
```
|
|
59
132
|
|
|
60
133
|
## ⚖️ power-queues vs Existing Solutions
|
|
@@ -124,27 +197,24 @@ and efficiently.
|
|
|
124
197
|
When retries reach the configured limit:
|
|
125
198
|
- the task is moved into `${stream}:dlq`
|
|
126
199
|
- includes: payload, attempt count, job, timestamp, error text
|
|
127
|
-
- fully JSON‑safe
|
|
128
200
|
|
|
129
201
|
Perfect for monitoring or later re‑processing.
|
|
130
202
|
|
|
131
203
|
### ✔ Zero‑Overhead Serialization
|
|
132
204
|
**power-queues** uses:
|
|
133
205
|
- safe JSON encoding
|
|
134
|
-
- optional "flat" key/value task format
|
|
135
206
|
- predictable and optimized payload transformation
|
|
136
207
|
|
|
137
208
|
This keeps Redis memory layout clean and eliminates overhead.
|
|
138
209
|
|
|
139
210
|
### ✔ Complete Set of Lifecycle Hooks
|
|
140
211
|
You can extend any part of the execution flow:
|
|
141
|
-
- `onSelected`
|
|
142
212
|
- `onExecute`
|
|
213
|
+
- `onBatchReady`
|
|
143
214
|
- `onSuccess`
|
|
144
215
|
- `onError`
|
|
145
|
-
- `onRetry`
|
|
146
216
|
- `onBatchError`
|
|
147
|
-
- `
|
|
217
|
+
- `onRetry`
|
|
148
218
|
|
|
149
219
|
This allows full integration with:
|
|
150
220
|
- monitoring systems
|
|
@@ -163,6 +233,7 @@ Ensures resilience in failover scenarios.
|
|
|
163
233
|
|
|
164
234
|
### ✔ Job Progress Tracking
|
|
165
235
|
Optional per‑job counters:
|
|
236
|
+
- `job:total`
|
|
166
237
|
- `job:ok`
|
|
167
238
|
- `job:err`
|
|
168
239
|
- `job:ready`
|
package/dist/index.cjs
CHANGED
|
@@ -326,7 +326,14 @@ var PowerQueues = class extends import_power_redis.PowerRedis {
|
|
|
326
326
|
} catch (err) {
|
|
327
327
|
await this.batchError(err, queueName, tasks);
|
|
328
328
|
try {
|
|
329
|
-
await this.approve(queueName, tasks.map((task) =>
|
|
329
|
+
await this.approve(queueName, tasks.map((task) => ({
|
|
330
|
+
id: task[0],
|
|
331
|
+
createdAt: Number(task[2]),
|
|
332
|
+
payload: task[1],
|
|
333
|
+
job: task[3],
|
|
334
|
+
idemKey: task[4],
|
|
335
|
+
attempt: Number(task[5] || 0)
|
|
336
|
+
})));
|
|
330
337
|
} catch {
|
|
331
338
|
}
|
|
332
339
|
await (0, import_full_utils.wait)(300);
|
|
@@ -337,17 +344,17 @@ var PowerQueues = class extends import_power_redis.PowerRedis {
|
|
|
337
344
|
try {
|
|
338
345
|
const filtered = {};
|
|
339
346
|
tasks.forEach((task, index) => {
|
|
340
|
-
const key =
|
|
347
|
+
const key = JSON.stringify([task[5] || "0", task[2], task[3]]);
|
|
341
348
|
if (!filtered[key]) {
|
|
342
349
|
filtered[key] = [];
|
|
343
350
|
}
|
|
344
|
-
filtered[key].push(tasks[index][1]);
|
|
351
|
+
filtered[key].push({ ...tasks[index][1], idemKey: tasks[index][4] });
|
|
345
352
|
});
|
|
346
353
|
for (let key in filtered) {
|
|
347
354
|
const filteredTasks = filtered[key];
|
|
348
|
-
const
|
|
349
|
-
const attempt = Number(
|
|
350
|
-
const job = String(
|
|
355
|
+
const keyP = JSON.parse(key);
|
|
356
|
+
const attempt = Number(keyP[0] || 0);
|
|
357
|
+
const job = String(keyP[2]);
|
|
351
358
|
if (!(attempt >= this.retryCount - 1)) {
|
|
352
359
|
await this.addTasks(queueName, filteredTasks, {
|
|
353
360
|
job,
|
|
@@ -363,12 +370,10 @@ var PowerQueues = class extends import_power_redis.PowerRedis {
|
|
|
363
370
|
}
|
|
364
371
|
}
|
|
365
372
|
} catch (err2) {
|
|
366
|
-
throw new Error(`Batch error. ${err2.message}`);
|
|
367
373
|
}
|
|
368
374
|
try {
|
|
369
375
|
await this.onBatchError(err, queueName, tasks);
|
|
370
376
|
} catch (err2) {
|
|
371
|
-
throw new Error(`Batch error. ${err2.message}`);
|
|
372
377
|
}
|
|
373
378
|
}
|
|
374
379
|
async approve(queueName, tasks) {
|
|
@@ -440,7 +445,7 @@ var PowerQueues = class extends import_power_redis.PowerRedis {
|
|
|
440
445
|
const kv = (0, import_full_utils.isArr)(kvRaw) ? kvRaw.map((x) => Buffer.isBuffer(x) ? x.toString() : x) : [];
|
|
441
446
|
return [id, kv];
|
|
442
447
|
}).filter(([id, kv]) => (0, import_full_utils.isStrFilled)(id) && (0, import_full_utils.isArr)(kv) && (kv.length & 1) === 0).map(([id, kv]) => {
|
|
443
|
-
const { payload, createdAt, job, idemKey
|
|
448
|
+
const { payload, createdAt, job, idemKey, attempt } = this.values(kv);
|
|
444
449
|
return [id, this.payload(payload), createdAt, job, idemKey, Number(attempt)];
|
|
445
450
|
});
|
|
446
451
|
}
|
|
@@ -567,7 +572,8 @@ var PowerQueues = class extends import_power_redis.PowerRedis {
|
|
|
567
572
|
await this.addTasks(queueName, [{ ...taskP.payload }], {
|
|
568
573
|
createdAt: taskP.createdAt,
|
|
569
574
|
job: taskP.job,
|
|
570
|
-
attempt: (taskP.attempt || 0) + 1
|
|
575
|
+
attempt: (taskP.attempt || 0) + 1,
|
|
576
|
+
idemKey: taskP.idemKey
|
|
571
577
|
});
|
|
572
578
|
} else if (this.logStatus) {
|
|
573
579
|
const dlqKey = queueName + ":dlq";
|
|
@@ -577,7 +583,8 @@ var PowerQueues = class extends import_power_redis.PowerRedis {
|
|
|
577
583
|
await this.addTasks(dlqKey, [{ ...taskP.payload }], {
|
|
578
584
|
createdAt: taskP.createdAt,
|
|
579
585
|
job: taskP.job,
|
|
580
|
-
attempt: taskP.attempt
|
|
586
|
+
attempt: taskP.attempt,
|
|
587
|
+
idemKey: taskP.idemKey
|
|
581
588
|
});
|
|
582
589
|
}
|
|
583
590
|
return await this.onError(err, queueName, { ...taskP, attempt: (taskP.attempt || 0) + 1 });
|
|
@@ -758,19 +765,17 @@ var PowerQueues = class extends import_power_redis.PowerRedis {
|
|
|
758
765
|
}
|
|
759
766
|
buildBatches(tasks, opts = {}) {
|
|
760
767
|
const batches = [];
|
|
761
|
-
let batch = [], realKeysLength =
|
|
768
|
+
let batch = [], realKeysLength = 8;
|
|
762
769
|
for (let task of tasks) {
|
|
763
770
|
const createdAt = opts?.createdAt || Date.now();
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
};
|
|
773
|
-
}
|
|
771
|
+
const { idemKey, ...taskP } = task;
|
|
772
|
+
const entry = {
|
|
773
|
+
payload: JSON.stringify(taskP),
|
|
774
|
+
attempt: Number(opts.attempt || 0),
|
|
775
|
+
job: opts.job ?? (0, import_uuid.v4)(),
|
|
776
|
+
idemKey: String((idemKey ?? opts?.idemKey) || (0, import_uuid.v4)()),
|
|
777
|
+
createdAt
|
|
778
|
+
};
|
|
774
779
|
const reqKeysLength = this.keysLength(entry);
|
|
775
780
|
if (batch.length && (batch.length >= this.buildBatchCount || realKeysLength + reqKeysLength > this.buildBatchMaxCount)) {
|
|
776
781
|
batches.push(batch);
|
|
@@ -786,9 +791,6 @@ var PowerQueues = class extends import_power_redis.PowerRedis {
|
|
|
786
791
|
return batches;
|
|
787
792
|
}
|
|
788
793
|
keysLength(task) {
|
|
789
|
-
if ("payload" in task && (0, import_full_utils.isObj)(task.payload)) {
|
|
790
|
-
return 2 + Object.keys(task.payload).length * 2;
|
|
791
|
-
}
|
|
792
794
|
return 2 + Object.keys(task).length * 2;
|
|
793
795
|
}
|
|
794
796
|
payloadBatch(data, opts) {
|
|
@@ -813,7 +815,7 @@ var PowerQueues = class extends import_power_redis.PowerRedis {
|
|
|
813
815
|
const entry = item;
|
|
814
816
|
const id = entry.id ?? "*";
|
|
815
817
|
let flat = [];
|
|
816
|
-
if ("payload" in entry
|
|
818
|
+
if ("payload" in entry) {
|
|
817
819
|
for (const [k, v] of Object.entries(entry)) {
|
|
818
820
|
flat.push(k, v);
|
|
819
821
|
}
|
|
@@ -821,7 +823,7 @@ var PowerQueues = class extends import_power_redis.PowerRedis {
|
|
|
821
823
|
throw new Error('Task must have "payload" or "flat".');
|
|
822
824
|
}
|
|
823
825
|
const pairs = flat.length / 2;
|
|
824
|
-
if (
|
|
826
|
+
if (pairs <= 0) {
|
|
825
827
|
throw new Error('Task "flat" must contain at least one field/value pair.');
|
|
826
828
|
}
|
|
827
829
|
argv.push(String(id));
|
package/dist/index.d.cts
CHANGED
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -2,13 +2,10 @@
|
|
|
2
2
|
import { setMaxListeners } from "events";
|
|
3
3
|
import { PowerRedis } from "power-redis";
|
|
4
4
|
import {
|
|
5
|
-
isObjFilled,
|
|
6
|
-
isObj,
|
|
7
5
|
isArrFilled,
|
|
8
6
|
isArr,
|
|
9
7
|
isStrFilled,
|
|
10
8
|
isExists,
|
|
11
|
-
isNumNZ,
|
|
12
9
|
wait
|
|
13
10
|
} from "full-utils";
|
|
14
11
|
import { v4 as uuid } from "uuid";
|
|
@@ -309,7 +306,14 @@ var PowerQueues = class extends PowerRedis {
|
|
|
309
306
|
} catch (err) {
|
|
310
307
|
await this.batchError(err, queueName, tasks);
|
|
311
308
|
try {
|
|
312
|
-
await this.approve(queueName, tasks.map((task) =>
|
|
309
|
+
await this.approve(queueName, tasks.map((task) => ({
|
|
310
|
+
id: task[0],
|
|
311
|
+
createdAt: Number(task[2]),
|
|
312
|
+
payload: task[1],
|
|
313
|
+
job: task[3],
|
|
314
|
+
idemKey: task[4],
|
|
315
|
+
attempt: Number(task[5] || 0)
|
|
316
|
+
})));
|
|
313
317
|
} catch {
|
|
314
318
|
}
|
|
315
319
|
await wait(300);
|
|
@@ -320,17 +324,17 @@ var PowerQueues = class extends PowerRedis {
|
|
|
320
324
|
try {
|
|
321
325
|
const filtered = {};
|
|
322
326
|
tasks.forEach((task, index) => {
|
|
323
|
-
const key =
|
|
327
|
+
const key = JSON.stringify([task[5] || "0", task[2], task[3]]);
|
|
324
328
|
if (!filtered[key]) {
|
|
325
329
|
filtered[key] = [];
|
|
326
330
|
}
|
|
327
|
-
filtered[key].push(tasks[index][1]);
|
|
331
|
+
filtered[key].push({ ...tasks[index][1], idemKey: tasks[index][4] });
|
|
328
332
|
});
|
|
329
333
|
for (let key in filtered) {
|
|
330
334
|
const filteredTasks = filtered[key];
|
|
331
|
-
const
|
|
332
|
-
const attempt = Number(
|
|
333
|
-
const job = String(
|
|
335
|
+
const keyP = JSON.parse(key);
|
|
336
|
+
const attempt = Number(keyP[0] || 0);
|
|
337
|
+
const job = String(keyP[2]);
|
|
334
338
|
if (!(attempt >= this.retryCount - 1)) {
|
|
335
339
|
await this.addTasks(queueName, filteredTasks, {
|
|
336
340
|
job,
|
|
@@ -346,12 +350,10 @@ var PowerQueues = class extends PowerRedis {
|
|
|
346
350
|
}
|
|
347
351
|
}
|
|
348
352
|
} catch (err2) {
|
|
349
|
-
throw new Error(`Batch error. ${err2.message}`);
|
|
350
353
|
}
|
|
351
354
|
try {
|
|
352
355
|
await this.onBatchError(err, queueName, tasks);
|
|
353
356
|
} catch (err2) {
|
|
354
|
-
throw new Error(`Batch error. ${err2.message}`);
|
|
355
357
|
}
|
|
356
358
|
}
|
|
357
359
|
async approve(queueName, tasks) {
|
|
@@ -423,7 +425,7 @@ var PowerQueues = class extends PowerRedis {
|
|
|
423
425
|
const kv = isArr(kvRaw) ? kvRaw.map((x) => Buffer.isBuffer(x) ? x.toString() : x) : [];
|
|
424
426
|
return [id, kv];
|
|
425
427
|
}).filter(([id, kv]) => isStrFilled(id) && isArr(kv) && (kv.length & 1) === 0).map(([id, kv]) => {
|
|
426
|
-
const { payload, createdAt, job, idemKey
|
|
428
|
+
const { payload, createdAt, job, idemKey, attempt } = this.values(kv);
|
|
427
429
|
return [id, this.payload(payload), createdAt, job, idemKey, Number(attempt)];
|
|
428
430
|
});
|
|
429
431
|
}
|
|
@@ -550,7 +552,8 @@ var PowerQueues = class extends PowerRedis {
|
|
|
550
552
|
await this.addTasks(queueName, [{ ...taskP.payload }], {
|
|
551
553
|
createdAt: taskP.createdAt,
|
|
552
554
|
job: taskP.job,
|
|
553
|
-
attempt: (taskP.attempt || 0) + 1
|
|
555
|
+
attempt: (taskP.attempt || 0) + 1,
|
|
556
|
+
idemKey: taskP.idemKey
|
|
554
557
|
});
|
|
555
558
|
} else if (this.logStatus) {
|
|
556
559
|
const dlqKey = queueName + ":dlq";
|
|
@@ -560,7 +563,8 @@ var PowerQueues = class extends PowerRedis {
|
|
|
560
563
|
await this.addTasks(dlqKey, [{ ...taskP.payload }], {
|
|
561
564
|
createdAt: taskP.createdAt,
|
|
562
565
|
job: taskP.job,
|
|
563
|
-
attempt: taskP.attempt
|
|
566
|
+
attempt: taskP.attempt,
|
|
567
|
+
idemKey: taskP.idemKey
|
|
564
568
|
});
|
|
565
569
|
}
|
|
566
570
|
return await this.onError(err, queueName, { ...taskP, attempt: (taskP.attempt || 0) + 1 });
|
|
@@ -741,19 +745,17 @@ var PowerQueues = class extends PowerRedis {
|
|
|
741
745
|
}
|
|
742
746
|
buildBatches(tasks, opts = {}) {
|
|
743
747
|
const batches = [];
|
|
744
|
-
let batch = [], realKeysLength =
|
|
748
|
+
let batch = [], realKeysLength = 8;
|
|
745
749
|
for (let task of tasks) {
|
|
746
750
|
const createdAt = opts?.createdAt || Date.now();
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
};
|
|
756
|
-
}
|
|
751
|
+
const { idemKey, ...taskP } = task;
|
|
752
|
+
const entry = {
|
|
753
|
+
payload: JSON.stringify(taskP),
|
|
754
|
+
attempt: Number(opts.attempt || 0),
|
|
755
|
+
job: opts.job ?? uuid(),
|
|
756
|
+
idemKey: String((idemKey ?? opts?.idemKey) || uuid()),
|
|
757
|
+
createdAt
|
|
758
|
+
};
|
|
757
759
|
const reqKeysLength = this.keysLength(entry);
|
|
758
760
|
if (batch.length && (batch.length >= this.buildBatchCount || realKeysLength + reqKeysLength > this.buildBatchMaxCount)) {
|
|
759
761
|
batches.push(batch);
|
|
@@ -769,9 +771,6 @@ var PowerQueues = class extends PowerRedis {
|
|
|
769
771
|
return batches;
|
|
770
772
|
}
|
|
771
773
|
keysLength(task) {
|
|
772
|
-
if ("payload" in task && isObj(task.payload)) {
|
|
773
|
-
return 2 + Object.keys(task.payload).length * 2;
|
|
774
|
-
}
|
|
775
774
|
return 2 + Object.keys(task).length * 2;
|
|
776
775
|
}
|
|
777
776
|
payloadBatch(data, opts) {
|
|
@@ -796,7 +795,7 @@ var PowerQueues = class extends PowerRedis {
|
|
|
796
795
|
const entry = item;
|
|
797
796
|
const id = entry.id ?? "*";
|
|
798
797
|
let flat = [];
|
|
799
|
-
if ("payload" in entry
|
|
798
|
+
if ("payload" in entry) {
|
|
800
799
|
for (const [k, v] of Object.entries(entry)) {
|
|
801
800
|
flat.push(k, v);
|
|
802
801
|
}
|
|
@@ -804,7 +803,7 @@ var PowerQueues = class extends PowerRedis {
|
|
|
804
803
|
throw new Error('Task must have "payload" or "flat".');
|
|
805
804
|
}
|
|
806
805
|
const pairs = flat.length / 2;
|
|
807
|
-
if (
|
|
806
|
+
if (pairs <= 0) {
|
|
808
807
|
throw new Error('Task "flat" must contain at least one field/value pair.');
|
|
809
808
|
}
|
|
810
809
|
argv.push(String(id));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "power-queues",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.8",
|
|
4
4
|
"description": "High-performance Redis Streams queue for Node.js with Lua-powered bulk XADD, idempotent workers, heartbeat locks, stuck-task recovery, retries, DLQ, and distributed processing.",
|
|
5
5
|
"author": "ihor-bielchenko",
|
|
6
6
|
"license": "MIT",
|