@yarkivaev/source-to-sink 1.1.1 → 1.2.3
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/.github/workflows/ci.yml +1 -1
- package/eslint.config.js +176 -0
- package/index.js +1 -0
- package/package.json +11 -5
- package/src/batch.js +13 -5
- package/src/lokiSource.js +3 -3
- package/src/modbusSource.js +4 -4
- package/src/mqttSource.js +5 -5
- package/src/pollingSource.js +4 -3
- package/src/postgresSink.js +7 -7
- package/src/sqliteSink.js +55 -0
- package/src/stompSource.js +20 -11
- package/src/timedBatch.js +4 -4
- package/test/integration_clickhouseSink.js +5 -5
- package/test/integration_lokiSource.js +13 -13
- package/test/integration_mqttSink.js +12 -12
- package/test/integration_mqttSource.js +19 -19
- package/test/test_batch.js +21 -35
- package/test/test_circuit.js +12 -12
- package/test/test_clickhouseSink.js +8 -8
- package/test/test_lokiSource.js +12 -12
- package/test/test_modbusSource.js +16 -16
- package/test/test_mqttSink.js +6 -6
- package/test/test_mqttSource.js +8 -8
- package/test/test_pollingSource.js +23 -23
- package/test/test_postgresSink.js +12 -12
- package/test/test_sqliteSink.js +176 -0
- package/test/test_stompSource.js +8 -8
- package/test/test_timedBatch.js +13 -13
package/.github/workflows/ci.yml
CHANGED
package/eslint.config.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import js from '@eslint/js';
|
|
2
|
+
import globals from 'globals';
|
|
3
|
+
|
|
4
|
+
export default [
|
|
5
|
+
js.configs.recommended,
|
|
6
|
+
{
|
|
7
|
+
languageOptions: {
|
|
8
|
+
ecmaVersion: 'latest',
|
|
9
|
+
sourceType: 'module',
|
|
10
|
+
globals: {
|
|
11
|
+
...globals.node,
|
|
12
|
+
...globals.mocha
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
rules: {
|
|
16
|
+
// Possible Problems
|
|
17
|
+
'array-callback-return': 'error',
|
|
18
|
+
'no-await-in-loop': 'error',
|
|
19
|
+
'no-constant-binary-expression': 'error',
|
|
20
|
+
'no-constructor-return': 'error',
|
|
21
|
+
'no-duplicate-imports': 'error',
|
|
22
|
+
'no-new-native-nonconstructor': 'error',
|
|
23
|
+
'no-promise-executor-return': 'error',
|
|
24
|
+
'no-self-compare': 'error',
|
|
25
|
+
'no-template-curly-in-string': 'error',
|
|
26
|
+
'no-unmodified-loop-condition': 'error',
|
|
27
|
+
'no-unreachable-loop': 'error',
|
|
28
|
+
'no-unused-private-class-members': 'error',
|
|
29
|
+
'no-use-before-define': 'error',
|
|
30
|
+
'require-atomic-updates': 'error',
|
|
31
|
+
|
|
32
|
+
// Suggestions
|
|
33
|
+
'accessor-pairs': 'error',
|
|
34
|
+
'arrow-body-style': ['error', 'always'],
|
|
35
|
+
'block-scoped-var': 'error',
|
|
36
|
+
'camelcase': 'error',
|
|
37
|
+
'consistent-return': 'error',
|
|
38
|
+
'consistent-this': ['error', 'self'],
|
|
39
|
+
'curly': ['error', 'all'],
|
|
40
|
+
'default-case': 'error',
|
|
41
|
+
'default-case-last': 'error',
|
|
42
|
+
'default-param-last': 'error',
|
|
43
|
+
'dot-notation': 'error',
|
|
44
|
+
'eqeqeq': ['error', 'always'],
|
|
45
|
+
'func-name-matching': 'error',
|
|
46
|
+
'func-names': ['error', 'always'],
|
|
47
|
+
'func-style': ['error', 'declaration'],
|
|
48
|
+
'grouped-accessor-pairs': ['error', 'getBeforeSet'],
|
|
49
|
+
'guard-for-in': 'error',
|
|
50
|
+
'id-denylist': 'off',
|
|
51
|
+
'id-length': ['error', { 'min': 2, 'exceptions': ['i', 'j', 'k', 'x', 'y', 'z', 'a', 'b', 'p', 'w', 'e', 'r'] }],
|
|
52
|
+
'max-classes-per-file': ['error', 1],
|
|
53
|
+
'max-depth': ['error', 3],
|
|
54
|
+
'max-lines': ['error', { 'max': 200, 'skipBlankLines': true, 'skipComments': true }],
|
|
55
|
+
'max-lines-per-function': ['error', { 'max': 50, 'skipBlankLines': true, 'skipComments': true }],
|
|
56
|
+
'max-nested-callbacks': ['error', 3],
|
|
57
|
+
'max-params': ['error', 5],
|
|
58
|
+
'max-statements': ['error', 15],
|
|
59
|
+
'new-cap': 'error',
|
|
60
|
+
'no-alert': 'error',
|
|
61
|
+
'no-array-constructor': 'error',
|
|
62
|
+
'no-bitwise': 'error',
|
|
63
|
+
'no-caller': 'error',
|
|
64
|
+
'no-console': 'error',
|
|
65
|
+
'no-continue': 'error',
|
|
66
|
+
'no-div-regex': 'error',
|
|
67
|
+
'no-else-return': 'error',
|
|
68
|
+
'no-empty-function': 'error',
|
|
69
|
+
'no-eq-null': 'error',
|
|
70
|
+
'no-eval': 'error',
|
|
71
|
+
'no-extend-native': 'error',
|
|
72
|
+
'no-extra-bind': 'error',
|
|
73
|
+
'no-extra-label': 'error',
|
|
74
|
+
'no-implicit-coercion': 'error',
|
|
75
|
+
'no-implicit-globals': 'error',
|
|
76
|
+
'no-implied-eval': 'error',
|
|
77
|
+
'no-inline-comments': 'error',
|
|
78
|
+
'no-invalid-this': 'error',
|
|
79
|
+
'no-iterator': 'error',
|
|
80
|
+
'no-label-var': 'error',
|
|
81
|
+
'no-labels': 'error',
|
|
82
|
+
'no-lone-blocks': 'error',
|
|
83
|
+
'no-lonely-if': 'error',
|
|
84
|
+
'no-loop-func': 'error',
|
|
85
|
+
'no-magic-numbers': 'off',
|
|
86
|
+
'no-multi-assign': 'error',
|
|
87
|
+
'no-multi-str': 'error',
|
|
88
|
+
'no-negated-condition': 'error',
|
|
89
|
+
'no-nested-ternary': 'error',
|
|
90
|
+
'no-new': 'error',
|
|
91
|
+
'no-new-func': 'error',
|
|
92
|
+
'no-new-wrappers': 'error',
|
|
93
|
+
'no-object-constructor': 'error',
|
|
94
|
+
'no-octal-escape': 'error',
|
|
95
|
+
'no-param-reassign': 'error',
|
|
96
|
+
'no-plusplus': 'error',
|
|
97
|
+
'no-proto': 'error',
|
|
98
|
+
'no-return-assign': 'error',
|
|
99
|
+
'no-script-url': 'error',
|
|
100
|
+
'no-sequences': 'error',
|
|
101
|
+
'no-shadow': 'error',
|
|
102
|
+
'no-throw-literal': 'error',
|
|
103
|
+
'no-undef-init': 'error',
|
|
104
|
+
'no-undefined': 'off',
|
|
105
|
+
'no-underscore-dangle': 'error',
|
|
106
|
+
'no-unneeded-ternary': 'error',
|
|
107
|
+
'no-unused-expressions': 'error',
|
|
108
|
+
'no-useless-call': 'error',
|
|
109
|
+
'no-useless-computed-key': 'error',
|
|
110
|
+
'no-useless-concat': 'error',
|
|
111
|
+
'no-useless-constructor': 'error',
|
|
112
|
+
'no-useless-rename': 'error',
|
|
113
|
+
'no-useless-return': 'error',
|
|
114
|
+
'no-var': 'error',
|
|
115
|
+
'no-void': 'off',
|
|
116
|
+
'no-warning-comments': 'warn',
|
|
117
|
+
'object-shorthand': ['error', 'always'],
|
|
118
|
+
'one-var': ['error', 'never'],
|
|
119
|
+
'operator-assignment': ['error', 'always'],
|
|
120
|
+
'prefer-arrow-callback': 'error',
|
|
121
|
+
'prefer-const': 'error',
|
|
122
|
+
'prefer-destructuring': ['error', { 'array': false, 'object': true }],
|
|
123
|
+
'prefer-exponentiation-operator': 'error',
|
|
124
|
+
'prefer-named-capture-group': 'error',
|
|
125
|
+
'prefer-numeric-literals': 'error',
|
|
126
|
+
'prefer-object-has-own': 'error',
|
|
127
|
+
'prefer-object-spread': 'error',
|
|
128
|
+
'prefer-promise-reject-errors': 'error',
|
|
129
|
+
'prefer-regex-literals': 'error',
|
|
130
|
+
'prefer-rest-params': 'error',
|
|
131
|
+
'prefer-spread': 'error',
|
|
132
|
+
'prefer-template': 'error',
|
|
133
|
+
'radix': 'error',
|
|
134
|
+
'require-await': 'error',
|
|
135
|
+
'require-unicode-regexp': 'error',
|
|
136
|
+
'sort-imports': ['error', { 'ignoreCase': true, 'ignoreDeclarationSort': true }],
|
|
137
|
+
'sort-keys': 'off',
|
|
138
|
+
'sort-vars': 'error',
|
|
139
|
+
'strict': ['error', 'never'],
|
|
140
|
+
'symbol-description': 'error',
|
|
141
|
+
'vars-on-top': 'error',
|
|
142
|
+
'yoda': 'error',
|
|
143
|
+
|
|
144
|
+
// Layout & Formatting
|
|
145
|
+
'line-comment-position': ['error', { 'position': 'above' }],
|
|
146
|
+
'unicode-bom': ['error', 'never']
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
files: ['src/modbusSource.js'],
|
|
151
|
+
rules: {
|
|
152
|
+
'max-params': ['error', 8],
|
|
153
|
+
'max-lines-per-function': ['error', { 'max': 60, 'skipBlankLines': true, 'skipComments': true }],
|
|
154
|
+
'max-statements': ['error', 20]
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
files: ['test/**/*.js'],
|
|
159
|
+
rules: {
|
|
160
|
+
'max-lines': 'off',
|
|
161
|
+
'max-lines-per-function': 'off',
|
|
162
|
+
'max-statements': 'off',
|
|
163
|
+
'max-nested-callbacks': ['error', 5],
|
|
164
|
+
'prefer-arrow-callback': 'off',
|
|
165
|
+
'func-names': 'off',
|
|
166
|
+
'id-denylist': 'off',
|
|
167
|
+
'no-undefined': 'off',
|
|
168
|
+
'no-empty-function': 'off',
|
|
169
|
+
'func-style': 'off',
|
|
170
|
+
'no-invalid-this': 'off',
|
|
171
|
+
'no-await-in-loop': 'off',
|
|
172
|
+
'camelcase': ['error', { 'properties': 'never' }],
|
|
173
|
+
'id-length': ['error', { 'min': 2, 'exceptions': ['i', 'j', 'k', 'x', 'y', 'z', 'a', 'b', 'p', 'w', 'e', 'r', 'c', 't', 'v', '_'] }]
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
];
|
package/index.js
CHANGED
|
@@ -37,3 +37,4 @@ export { default as stompSource } from './src/stompSource.js';
|
|
|
37
37
|
export { default as lokiSource } from './src/lokiSource.js';
|
|
38
38
|
export { default as modbusSource } from './src/modbusSource.js';
|
|
39
39
|
export { default as mqttSink } from './src/mqttSink.js';
|
|
40
|
+
export { default as sqliteSink } from './src/sqliteSink.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yarkivaev/source-to-sink",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"description": "Generic library for building data streaming pipelines",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -13,15 +13,21 @@
|
|
|
13
13
|
"@clickhouse/client": "^1.0.0",
|
|
14
14
|
"stompit": "^1.0.0",
|
|
15
15
|
"pg": "^8.0.0",
|
|
16
|
-
"modbus-serial": "^8.0.0"
|
|
16
|
+
"modbus-serial": "^8.0.0",
|
|
17
|
+
"better-sqlite3": "^11.0.0"
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
|
19
|
-
"
|
|
20
|
+
"@eslint/js": "^9.39.2",
|
|
20
21
|
"c8": "^8.0.0",
|
|
22
|
+
"eslint": "^9.39.2",
|
|
23
|
+
"globals": "^17.0.0",
|
|
24
|
+
"mocha": "^10.0.0",
|
|
21
25
|
"testcontainers": "^10.13.0"
|
|
22
26
|
},
|
|
23
27
|
"scripts": {
|
|
24
|
-
"test": "mocha test/*.js",
|
|
25
|
-
"coverage": "c8 mocha test/*.js"
|
|
28
|
+
"test": "mocha test/*.js --exit",
|
|
29
|
+
"coverage": "c8 mocha test/*.js",
|
|
30
|
+
"lint": "eslint .",
|
|
31
|
+
"lint:fix": "eslint . --fix"
|
|
26
32
|
}
|
|
27
33
|
}
|
package/src/batch.js
CHANGED
|
@@ -30,7 +30,7 @@ export default function batch(sink, size, circuit) {
|
|
|
30
30
|
throw new Error('Circuit must have an allowing() method');
|
|
31
31
|
}
|
|
32
32
|
let records = [];
|
|
33
|
-
|
|
33
|
+
function perform() {
|
|
34
34
|
if (records.length === 0) {
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
@@ -38,15 +38,23 @@ export default function batch(sink, size, circuit) {
|
|
|
38
38
|
return;
|
|
39
39
|
}
|
|
40
40
|
const pending = records;
|
|
41
|
+
records = [];
|
|
41
42
|
try {
|
|
42
|
-
sink.write(pending);
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
const result = sink.write(pending);
|
|
44
|
+
if (result && typeof result.then === 'function') {
|
|
45
|
+
result.then(
|
|
46
|
+
() => { circuit.succeed(); },
|
|
47
|
+
() => { circuit.fail(); records = pending.concat(records); }
|
|
48
|
+
);
|
|
49
|
+
} else {
|
|
50
|
+
circuit.succeed();
|
|
51
|
+
}
|
|
45
52
|
} catch (err) {
|
|
46
53
|
circuit.fail();
|
|
54
|
+
records = pending.concat(records);
|
|
47
55
|
throw err;
|
|
48
56
|
}
|
|
49
|
-
}
|
|
57
|
+
}
|
|
50
58
|
return {
|
|
51
59
|
/**
|
|
52
60
|
* Accepts a record into the batch.
|
package/src/lokiSource.js
CHANGED
|
@@ -26,9 +26,9 @@ export default function lokiSource(url, query, interval, collector, clk) {
|
|
|
26
26
|
if (typeof query !== 'string' || query.length === 0) {
|
|
27
27
|
throw new Error('Query must be a non-empty string');
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
async function fetch(since, until) {
|
|
30
30
|
const params = new URLSearchParams({
|
|
31
|
-
query
|
|
31
|
+
query,
|
|
32
32
|
start: (since * 1000000).toString(),
|
|
33
33
|
end: (until * 1000000).toString(),
|
|
34
34
|
limit: '1000'
|
|
@@ -44,6 +44,6 @@ export default function lokiSource(url, query, interval, collector, clk) {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
return entries;
|
|
47
|
-
}
|
|
47
|
+
}
|
|
48
48
|
return pollingSource(fetch, interval, collector, clk);
|
|
49
49
|
}
|
package/src/modbusSource.js
CHANGED
|
@@ -95,14 +95,14 @@ export default function modbusSource(host, port, address, count, interval, colle
|
|
|
95
95
|
}
|
|
96
96
|
const client = new ModbusRTU();
|
|
97
97
|
let state = idle();
|
|
98
|
-
|
|
98
|
+
async function fetch() {
|
|
99
99
|
const result = await client.readHoldingRegisters(address, count);
|
|
100
100
|
return [result.data];
|
|
101
|
-
}
|
|
101
|
+
}
|
|
102
102
|
const maxDelay = 300;
|
|
103
103
|
const source = pollingSource(fetch, interval, collector, clk);
|
|
104
104
|
let delay = interval;
|
|
105
|
-
|
|
105
|
+
function attempt() {
|
|
106
106
|
client.connectTCP(host, { port }).then(() => {
|
|
107
107
|
delay = interval;
|
|
108
108
|
state = connected(client);
|
|
@@ -113,7 +113,7 @@ export default function modbusSource(host, port, address, count, interval, colle
|
|
|
113
113
|
state = retrying(handle);
|
|
114
114
|
delay = Math.min(delay * 2, maxDelay);
|
|
115
115
|
});
|
|
116
|
-
}
|
|
116
|
+
}
|
|
117
117
|
return {
|
|
118
118
|
/**
|
|
119
119
|
* Connects to the Modbus device and starts polling.
|
package/src/mqttSource.js
CHANGED
|
@@ -65,7 +65,7 @@ export default function mqttSource(url, topics, collector, options = {}) {
|
|
|
65
65
|
if (!collector || typeof collector.accept !== 'function') {
|
|
66
66
|
throw new Error('Collector must have an accept() method');
|
|
67
67
|
}
|
|
68
|
-
const list = topics.split(',').map((
|
|
68
|
+
const list = topics.split(',').map((tp) => {return tp.trim()}).filter((tp) => {return tp.length > 0});
|
|
69
69
|
let state = idle();
|
|
70
70
|
return {
|
|
71
71
|
/**
|
|
@@ -77,15 +77,15 @@ export default function mqttSource(url, topics, collector, options = {}) {
|
|
|
77
77
|
}
|
|
78
78
|
const client = mqtt.connect(url, {
|
|
79
79
|
clientId: options.clientId,
|
|
80
|
-
clean: options.clientId
|
|
80
|
+
clean: !options.clientId,
|
|
81
81
|
protocolVersion: 5,
|
|
82
82
|
properties: options.clientId ? {
|
|
83
83
|
sessionExpiryInterval: options.sessionExpiryInterval || 3600
|
|
84
84
|
} : undefined
|
|
85
85
|
});
|
|
86
|
-
|
|
87
|
-
collector.accept({ topic:
|
|
88
|
-
}
|
|
86
|
+
function handler(tp, message) {
|
|
87
|
+
collector.accept({ topic: tp, payload: message.toString() });
|
|
88
|
+
}
|
|
89
89
|
client.on('message', handler);
|
|
90
90
|
client.on('connect', () => {
|
|
91
91
|
client.subscribe(list, { qos: options.clientId ? 1 : 0 });
|
package/src/pollingSource.js
CHANGED
|
@@ -64,14 +64,15 @@ export default function pollingSource(fetch, interval, collector, clk) {
|
|
|
64
64
|
}
|
|
65
65
|
let state = idle();
|
|
66
66
|
let since = clk.millis();
|
|
67
|
-
|
|
67
|
+
async function poll() {
|
|
68
|
+
const from = since;
|
|
68
69
|
const until = clk.millis();
|
|
69
|
-
const result = await fetch(since, until);
|
|
70
70
|
since = until;
|
|
71
|
+
const result = await fetch(from, until);
|
|
71
72
|
for (const entry of result) {
|
|
72
73
|
collector.accept(entry);
|
|
73
74
|
}
|
|
74
|
-
}
|
|
75
|
+
}
|
|
75
76
|
return {
|
|
76
77
|
/**
|
|
77
78
|
* Starts polling.
|
package/src/postgresSink.js
CHANGED
|
@@ -45,10 +45,10 @@ import pg from 'pg';
|
|
|
45
45
|
* @returns {Array} Deduplicated records
|
|
46
46
|
*/
|
|
47
47
|
export function deduplicate(records, keys) {
|
|
48
|
-
if (keys.length === 0) return records;
|
|
48
|
+
if (keys.length === 0) {return records;}
|
|
49
49
|
const seen = new Map();
|
|
50
50
|
for (const record of records) {
|
|
51
|
-
const key = keys.map(k => record[k]).join('\0');
|
|
51
|
+
const key = keys.map(k => {return record[k]}).join('\0');
|
|
52
52
|
seen.set(key, record);
|
|
53
53
|
}
|
|
54
54
|
return Array.from(seen.values());
|
|
@@ -61,10 +61,10 @@ export function deduplicate(records, keys) {
|
|
|
61
61
|
* @returns {string} SQL suffix or empty string
|
|
62
62
|
*/
|
|
63
63
|
function buildSuffix(options) {
|
|
64
|
-
if (!Array.isArray(options.conflict) || options.conflict.length === 0) return '';
|
|
64
|
+
if (!Array.isArray(options.conflict) || options.conflict.length === 0) {return '';}
|
|
65
65
|
const cols = options.conflict.join(', ');
|
|
66
66
|
if (Array.isArray(options.update) && options.update.length > 0) {
|
|
67
|
-
const sets = options.update.map(
|
|
67
|
+
const sets = options.update.map((col) => {return `${col} = EXCLUDED.${col}`}).join(', ');
|
|
68
68
|
return ` ON CONFLICT (${cols}) DO UPDATE SET ${sets}`;
|
|
69
69
|
}
|
|
70
70
|
return ` ON CONFLICT (${cols}) DO NOTHING`;
|
|
@@ -92,13 +92,13 @@ export default function postgresSink(url, table, columns, options = {}) {
|
|
|
92
92
|
*/
|
|
93
93
|
write(records) {
|
|
94
94
|
const unique = deduplicate(records, conflict);
|
|
95
|
-
const placeholders = unique.map((
|
|
95
|
+
const placeholders = unique.map((record, i) => {
|
|
96
96
|
const offset = i * columns.length;
|
|
97
|
-
const row = columns.map((__, j) => `$${offset + j + 1}`).join(', ');
|
|
97
|
+
const row = columns.map((__, j) => {return `$${offset + j + 1}`}).join(', ');
|
|
98
98
|
return `(${row})`;
|
|
99
99
|
}).join(', ');
|
|
100
100
|
const query = `INSERT INTO ${table} (${columns.join(', ')}) VALUES ${placeholders}${suffix}`;
|
|
101
|
-
const values = unique.flatMap((record) => columns.map((col) => record[col]));
|
|
101
|
+
const values = unique.flatMap((record) => {return columns.map((col) => {return record[col]})});
|
|
102
102
|
return pool.query(query, values);
|
|
103
103
|
}
|
|
104
104
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SQLite sink for batch record insertion.
|
|
5
|
+
*
|
|
6
|
+
* Opens a SQLite database in WAL mode and implements the Sink
|
|
7
|
+
* interface for use with batch collectors. Creates the target
|
|
8
|
+
* table with the given columns if it does not exist.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const sink = sqliteSink('/tmp/metrics.db', 'metrics', ['topic', 'ts', 'value']);
|
|
12
|
+
* sink.write([{ topic: 'sensor/1', ts: 1700000000000, value: 42 }]);
|
|
13
|
+
*
|
|
14
|
+
* @param {string} path - Filesystem path to the SQLite database file
|
|
15
|
+
* @param {string} table - Target table name
|
|
16
|
+
* @param {Array<string>} columns - Column names for insertion
|
|
17
|
+
* @returns {object} Sink with write(records) and close() methods
|
|
18
|
+
*/
|
|
19
|
+
export default function sqliteSink(path, table, columns) {
|
|
20
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
21
|
+
throw new Error('Path must be a non-empty string');
|
|
22
|
+
}
|
|
23
|
+
if (typeof table !== 'string' || table.length === 0) {
|
|
24
|
+
throw new Error('Table must be a non-empty string');
|
|
25
|
+
}
|
|
26
|
+
if (!Array.isArray(columns) || columns.length === 0) {
|
|
27
|
+
throw new Error('Columns must be a non-empty array');
|
|
28
|
+
}
|
|
29
|
+
const db = new Database(path);
|
|
30
|
+
db.pragma('journal_mode = WAL');
|
|
31
|
+
const placeholders = columns.map(() => {return '?'}).join(', ');
|
|
32
|
+
const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`;
|
|
33
|
+
const statement = db.prepare(sql);
|
|
34
|
+
const transaction = db.transaction((records) => {
|
|
35
|
+
for (const record of records) {
|
|
36
|
+
statement.run(...columns.map((col) => {return record[col]}));
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
/**
|
|
41
|
+
* Writes records to SQLite table inside a transaction.
|
|
42
|
+
*
|
|
43
|
+
* @param {Array} records - Array of objects with keys matching columns
|
|
44
|
+
*/
|
|
45
|
+
write(records) {
|
|
46
|
+
transaction(records);
|
|
47
|
+
},
|
|
48
|
+
/**
|
|
49
|
+
* Closes the database connection.
|
|
50
|
+
*/
|
|
51
|
+
close() {
|
|
52
|
+
db.close();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/stompSource.js
CHANGED
|
@@ -54,6 +54,25 @@ function subscribed(channel) {
|
|
|
54
54
|
* @param {string} [options.host] - Virtual host for STOMP connection
|
|
55
55
|
* @returns {object} Source with start() and stop() methods
|
|
56
56
|
*/
|
|
57
|
+
/**
|
|
58
|
+
* Builds STOMP server configuration from a URL.
|
|
59
|
+
*
|
|
60
|
+
* @param {URL} parsed - Parsed URL object
|
|
61
|
+
* @param {object} options - Connection options
|
|
62
|
+
* @returns {object} Server config for ConnectFailover
|
|
63
|
+
*/
|
|
64
|
+
function serverConfig(parsed, options) {
|
|
65
|
+
return {
|
|
66
|
+
host: parsed.hostname,
|
|
67
|
+
port: parseInt(parsed.port, 10) || 61613,
|
|
68
|
+
connectHeaders: {
|
|
69
|
+
host: options.host || parsed.hostname,
|
|
70
|
+
login: options.login || '',
|
|
71
|
+
passcode: options.passcode || ''
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
export default function stompSource(url, destination, collector, options = {}) {
|
|
58
77
|
if (typeof url !== 'string' || url.length === 0) {
|
|
59
78
|
throw new Error('URL must be a non-empty string');
|
|
@@ -74,17 +93,7 @@ export default function stompSource(url, destination, collector, options = {}) {
|
|
|
74
93
|
if (state.subscribed()) {
|
|
75
94
|
return;
|
|
76
95
|
}
|
|
77
|
-
const
|
|
78
|
-
host: options.host || parsed.hostname,
|
|
79
|
-
login: options.login || '',
|
|
80
|
-
passcode: options.passcode || ''
|
|
81
|
-
};
|
|
82
|
-
const server = {
|
|
83
|
-
host: parsed.hostname,
|
|
84
|
-
port: parseInt(parsed.port, 10) || 61613,
|
|
85
|
-
connectHeaders: headers
|
|
86
|
-
};
|
|
87
|
-
const failover = new stompit.ConnectFailover([server], {
|
|
96
|
+
const failover = new stompit.ConnectFailover([serverConfig(parsed, options)], {
|
|
88
97
|
initialReconnectDelay: 10,
|
|
89
98
|
maxReconnectDelay: 30000,
|
|
90
99
|
maxReconnects: -1
|
package/src/timedBatch.js
CHANGED
|
@@ -63,7 +63,7 @@ export default function timedBatch(origin, interval) {
|
|
|
63
63
|
throw new Error(`Interval must be a positive number, got: ${interval}`);
|
|
64
64
|
}
|
|
65
65
|
let timer = idle();
|
|
66
|
-
|
|
66
|
+
function schedule() {
|
|
67
67
|
if (!timer.scheduled()) {
|
|
68
68
|
const handle = setTimeout(() => {
|
|
69
69
|
timer = idle();
|
|
@@ -71,11 +71,11 @@ export default function timedBatch(origin, interval) {
|
|
|
71
71
|
}, interval * 1000);
|
|
72
72
|
timer = scheduled(handle);
|
|
73
73
|
}
|
|
74
|
-
}
|
|
75
|
-
|
|
74
|
+
}
|
|
75
|
+
function cancel() {
|
|
76
76
|
timer.cancel();
|
|
77
77
|
timer = idle();
|
|
78
|
-
}
|
|
78
|
+
}
|
|
79
79
|
return {
|
|
80
80
|
/**
|
|
81
81
|
* Accepts a record and schedules flush timer.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert';
|
|
2
|
-
import {
|
|
2
|
+
import { after, before, describe, it } from 'mocha';
|
|
3
3
|
import { GenericContainer } from 'testcontainers';
|
|
4
4
|
import { createClient } from '@clickhouse/client';
|
|
5
5
|
import clickhouseSink from '../src/clickhouseSink.js';
|
|
@@ -26,7 +26,7 @@ describe('clickhouseSink integration', function() {
|
|
|
26
26
|
break;
|
|
27
27
|
} catch {
|
|
28
28
|
retries -= 1;
|
|
29
|
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
29
|
+
await new Promise((resolve) => { setTimeout(resolve, 1000); });
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
await client.command({ query: 'CREATE DATABASE IF NOT EXISTS test' });
|
|
@@ -55,7 +55,7 @@ describe('clickhouseSink integration', function() {
|
|
|
55
55
|
const ts = Date.now();
|
|
56
56
|
const sink = clickhouseSink(url, 'test.metrics');
|
|
57
57
|
await sink.write([{ topic, ts, value }]);
|
|
58
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
58
|
+
await new Promise((resolve) => { setTimeout(resolve, 500); });
|
|
59
59
|
const result = await client.query({
|
|
60
60
|
query: `SELECT * FROM test.metrics WHERE topic = '${topic}'`,
|
|
61
61
|
format: 'JSONEachRow'
|
|
@@ -73,7 +73,7 @@ describe('clickhouseSink integration', function() {
|
|
|
73
73
|
];
|
|
74
74
|
const sink = clickhouseSink(url, 'test.metrics');
|
|
75
75
|
await sink.write(records);
|
|
76
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
76
|
+
await new Promise((resolve) => { setTimeout(resolve, 500); });
|
|
77
77
|
const result = await client.query({
|
|
78
78
|
query: `SELECT * FROM test.metrics WHERE topic = '${topic}'`,
|
|
79
79
|
format: 'JSONEachRow'
|
|
@@ -88,7 +88,7 @@ describe('clickhouseSink integration', function() {
|
|
|
88
88
|
const ts = Date.now();
|
|
89
89
|
const sink = clickhouseSink(url, 'test.metrics');
|
|
90
90
|
await sink.write([{ topic, ts, value }]);
|
|
91
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
91
|
+
await new Promise((resolve) => { setTimeout(resolve, 500); });
|
|
92
92
|
const result = await client.query({
|
|
93
93
|
query: `SELECT * FROM test.metrics WHERE topic = '${topic}'`,
|
|
94
94
|
format: 'JSONEachRow'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert';
|
|
2
|
-
import {
|
|
2
|
+
import { after, before, describe, it } from 'mocha';
|
|
3
3
|
import { GenericContainer } from 'testcontainers';
|
|
4
4
|
import lokiSource from '../src/lokiSource.js';
|
|
5
5
|
import clock from '../src/clock.js';
|
|
@@ -50,7 +50,7 @@ describe('lokiSource integration', function() {
|
|
|
50
50
|
// Loki not ready yet
|
|
51
51
|
}
|
|
52
52
|
retries -= 1;
|
|
53
|
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
53
|
+
await new Promise((resolve) => { setTimeout(resolve, 1000); });
|
|
54
54
|
}
|
|
55
55
|
});
|
|
56
56
|
|
|
@@ -65,13 +65,13 @@ describe('lokiSource integration', function() {
|
|
|
65
65
|
this.timeout(30000);
|
|
66
66
|
const received = [];
|
|
67
67
|
const app = `app${Math.random().toString(36).slice(2)}`;
|
|
68
|
-
const collector = { accept: (entry) => received.push(entry) };
|
|
68
|
+
const collector = { accept: (entry) => {return received.push(entry)} };
|
|
69
69
|
const clk = clock();
|
|
70
70
|
const source = lokiSource(url, `{app="${app}"}`, 0.5, collector, clk);
|
|
71
71
|
source.start();
|
|
72
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
72
|
+
await new Promise((resolve) => { setTimeout(resolve, 500); });
|
|
73
73
|
await pushToLoki(url, { app }, `test entry ${Math.random()}`);
|
|
74
|
-
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
74
|
+
await new Promise((resolve) => { setTimeout(resolve, 3000); });
|
|
75
75
|
source.stop();
|
|
76
76
|
assert.strictEqual(received.length >= 1, true, 'Should receive entries from Loki');
|
|
77
77
|
});
|
|
@@ -80,13 +80,13 @@ describe('lokiSource integration', function() {
|
|
|
80
80
|
this.timeout(30000);
|
|
81
81
|
const received = [];
|
|
82
82
|
const app = `app${Math.random().toString(36).slice(2)}`;
|
|
83
|
-
const collector = { accept: (entry) => received.push(entry) };
|
|
83
|
+
const collector = { accept: (entry) => {return received.push(entry)} };
|
|
84
84
|
const clk = clock();
|
|
85
85
|
const source = lokiSource(url, `{app="${app}"}`, 0.5, collector, clk);
|
|
86
86
|
source.start();
|
|
87
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
87
|
+
await new Promise((resolve) => { setTimeout(resolve, 500); });
|
|
88
88
|
await pushToLoki(url, { app }, `тест запись ${Math.random()}`);
|
|
89
|
-
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
89
|
+
await new Promise((resolve) => { setTimeout(resolve, 3000); });
|
|
90
90
|
source.stop();
|
|
91
91
|
assert.strictEqual(received.length >= 1, true, 'Should receive entries with unicode');
|
|
92
92
|
});
|
|
@@ -95,15 +95,15 @@ describe('lokiSource integration', function() {
|
|
|
95
95
|
this.timeout(30000);
|
|
96
96
|
const received = [];
|
|
97
97
|
const app = `app${Math.random().toString(36).slice(2)}`;
|
|
98
|
-
const collector = { accept: (entry) => received.push(entry) };
|
|
98
|
+
const collector = { accept: (entry) => {return received.push(entry)} };
|
|
99
99
|
const clk = clock();
|
|
100
100
|
const source = lokiSource(url, `{app="${app}"}`, 0.5, collector, clk);
|
|
101
101
|
source.start();
|
|
102
|
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
102
|
+
await new Promise((resolve) => { setTimeout(resolve, 1000); });
|
|
103
103
|
source.stop();
|
|
104
|
-
const
|
|
104
|
+
const snapshot = received.length;
|
|
105
105
|
await pushToLoki(url, { app }, `after stop ${Math.random()}`);
|
|
106
|
-
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
107
|
-
assert.strictEqual(received.length,
|
|
106
|
+
await new Promise((resolve) => { setTimeout(resolve, 2000); });
|
|
107
|
+
assert.strictEqual(received.length, snapshot, 'Should not receive entries after stop');
|
|
108
108
|
});
|
|
109
109
|
});
|