@yarkivaev/source-to-sink 1.0.3 → 1.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/index.js +4 -0
- package/package.json +3 -2
- package/src/modbusSource.js +99 -0
- package/src/mqttSink.js +98 -0
- package/src/postgresSink.js +33 -2
- package/test/integration_mqttSink.js +101 -0
- package/test/test_modbusSource.js +96 -0
- package/test/test_mqttSink.js +51 -0
- package/test/test_postgresSink.js +29 -1
package/index.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* - mqttSource: MQTT subscription source (accepts URL)
|
|
13
13
|
* - stompSource: STOMP subscription source (accepts URL)
|
|
14
14
|
* - lokiSource: Loki polling source (accepts URL)
|
|
15
|
+
* - modbusSource: Modbus TCP polling source (accepts host/port)
|
|
16
|
+
* - mqttSink: MQTT publishing sink (accepts URL)
|
|
15
17
|
*
|
|
16
18
|
* @example
|
|
17
19
|
* import { batch, circuit, timedBatch, clock, clickhouseSink, mqttSource } from 'source-to-sink';
|
|
@@ -33,3 +35,5 @@ export { default as postgresSink } from './src/postgresSink.js';
|
|
|
33
35
|
export { default as mqttSource } from './src/mqttSource.js';
|
|
34
36
|
export { default as stompSource } from './src/stompSource.js';
|
|
35
37
|
export { default as lokiSource } from './src/lokiSource.js';
|
|
38
|
+
export { default as modbusSource } from './src/modbusSource.js';
|
|
39
|
+
export { default as mqttSink } from './src/mqttSink.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yarkivaev/source-to-sink",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Generic library for building data streaming pipelines",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"mqtt": "^5.0.0",
|
|
13
13
|
"@clickhouse/client": "^1.0.0",
|
|
14
14
|
"stompit": "^1.0.0",
|
|
15
|
-
"pg": "^8.0.0"
|
|
15
|
+
"pg": "^8.0.0",
|
|
16
|
+
"modbus-serial": "^8.0.0"
|
|
16
17
|
},
|
|
17
18
|
"devDependencies": {
|
|
18
19
|
"mocha": "^10.0.0",
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import ModbusRTU from 'modbus-serial';
|
|
2
|
+
import pollingSource from './pollingSource.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Idle state for Modbus connection.
|
|
6
|
+
*
|
|
7
|
+
* @returns {object} State with connected() returning false
|
|
8
|
+
*/
|
|
9
|
+
function idle() {
|
|
10
|
+
return {
|
|
11
|
+
connected() {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Connected state for Modbus connection.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} client - ModbusRTU client
|
|
21
|
+
* @returns {object} State with connected() returning true
|
|
22
|
+
*/
|
|
23
|
+
function connected(client) {
|
|
24
|
+
return {
|
|
25
|
+
connected() {
|
|
26
|
+
return true;
|
|
27
|
+
},
|
|
28
|
+
disconnect() {
|
|
29
|
+
client.close();
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Modbus TCP polling source for reading holding registers.
|
|
36
|
+
*
|
|
37
|
+
* Connects to a Modbus TCP device and polls holding registers
|
|
38
|
+
* at a specified interval. Forwards register arrays to the
|
|
39
|
+
* collector via pollingSource.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* const source = modbusSource('192.168.2.148', 502, 4000, 14, 5, collector, clock);
|
|
43
|
+
* source.start();
|
|
44
|
+
* // ... later
|
|
45
|
+
* source.stop();
|
|
46
|
+
*
|
|
47
|
+
* @param {string} host - Modbus TCP host address
|
|
48
|
+
* @param {number} port - Modbus TCP port
|
|
49
|
+
* @param {number} address - Starting register address
|
|
50
|
+
* @param {number} count - Number of registers to read
|
|
51
|
+
* @param {number} interval - Polling interval in seconds
|
|
52
|
+
* @param {object} collector - Collector with accept() method
|
|
53
|
+
* @param {object} clk - Clock with millis() method
|
|
54
|
+
* @returns {object} Source with start() and stop() methods
|
|
55
|
+
*/
|
|
56
|
+
export default function modbusSource(host, port, address, count, interval, collector, clk) {
|
|
57
|
+
if (typeof host !== 'string' || host.length === 0) {
|
|
58
|
+
throw new Error('Host must be a non-empty string');
|
|
59
|
+
}
|
|
60
|
+
if (typeof port !== 'number' || port <= 0) {
|
|
61
|
+
throw new Error(`Port must be a positive number, got: ${port}`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof address !== 'number' || address < 0) {
|
|
64
|
+
throw new Error(`Address must be a non-negative number, got: ${address}`);
|
|
65
|
+
}
|
|
66
|
+
if (typeof count !== 'number' || count <= 0) {
|
|
67
|
+
throw new Error(`Count must be a positive number, got: ${count}`);
|
|
68
|
+
}
|
|
69
|
+
const client = new ModbusRTU();
|
|
70
|
+
let state = idle();
|
|
71
|
+
const fetch = async () => {
|
|
72
|
+
const result = await client.readHoldingRegisters(address, count);
|
|
73
|
+
return [result.data];
|
|
74
|
+
};
|
|
75
|
+
const source = pollingSource(fetch, interval, collector, clk);
|
|
76
|
+
return {
|
|
77
|
+
/**
|
|
78
|
+
* Connects to the Modbus device and starts polling.
|
|
79
|
+
*/
|
|
80
|
+
async start() {
|
|
81
|
+
if (state.connected()) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
await client.connectTCP(host, { port });
|
|
85
|
+
state = connected(client);
|
|
86
|
+
source.start();
|
|
87
|
+
},
|
|
88
|
+
/**
|
|
89
|
+
* Stops polling and disconnects from the Modbus device.
|
|
90
|
+
*/
|
|
91
|
+
stop() {
|
|
92
|
+
source.stop();
|
|
93
|
+
if (state.connected()) {
|
|
94
|
+
state.disconnect();
|
|
95
|
+
state = idle();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
package/src/mqttSink.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import mqtt from 'mqtt';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Idle state for MQTT sink.
|
|
5
|
+
*
|
|
6
|
+
* @returns {object} State with connected() returning false
|
|
7
|
+
*/
|
|
8
|
+
function idle() {
|
|
9
|
+
return {
|
|
10
|
+
connected() {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Connected state for MQTT sink.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} client - MQTT client
|
|
20
|
+
* @returns {object} State with connected() returning true
|
|
21
|
+
*/
|
|
22
|
+
function connected(client) {
|
|
23
|
+
return {
|
|
24
|
+
connected() {
|
|
25
|
+
return true;
|
|
26
|
+
},
|
|
27
|
+
publish(topic, payload, qos) {
|
|
28
|
+
client.publish(topic, payload, { qos });
|
|
29
|
+
},
|
|
30
|
+
disconnect() {
|
|
31
|
+
client.end();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* MQTT sink for publishing records to MQTT topics.
|
|
38
|
+
*
|
|
39
|
+
* Each record must have a topic and payload field.
|
|
40
|
+
* Creates an MQTT client internally and manages the connection.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* const sink = mqttSink('mqtt://localhost:1883');
|
|
44
|
+
* sink.start();
|
|
45
|
+
* sink.write([{ topic: 'sensors/temp', payload: '42.5' }]);
|
|
46
|
+
* sink.stop();
|
|
47
|
+
*
|
|
48
|
+
* @param {string} url - MQTT broker URL (e.g., 'mqtt://localhost:1883')
|
|
49
|
+
* @param {object} [options] - Optional MQTT connection options
|
|
50
|
+
* @param {string} [options.clientId] - Client ID for the connection
|
|
51
|
+
* @param {number} [options.qos] - QoS level for publishing (default 0)
|
|
52
|
+
* @returns {object} Sink with write(), start(), and stop() methods
|
|
53
|
+
*/
|
|
54
|
+
export default function mqttSink(url, options = {}) {
|
|
55
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
56
|
+
throw new Error('URL must be a non-empty string');
|
|
57
|
+
}
|
|
58
|
+
const qos = options.qos || 0;
|
|
59
|
+
let state = idle();
|
|
60
|
+
return {
|
|
61
|
+
/**
|
|
62
|
+
* Writes records to MQTT topics.
|
|
63
|
+
*
|
|
64
|
+
* @param {Array} records - Array of {topic, payload} records
|
|
65
|
+
*/
|
|
66
|
+
write(records) {
|
|
67
|
+
if (!state.connected()) {
|
|
68
|
+
throw new Error('Sink is not connected');
|
|
69
|
+
}
|
|
70
|
+
for (const record of records) {
|
|
71
|
+
state.publish(record.topic, record.payload, qos);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
/**
|
|
75
|
+
* Connects to the MQTT broker.
|
|
76
|
+
*/
|
|
77
|
+
start() {
|
|
78
|
+
if (state.connected()) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const client = mqtt.connect(url, {
|
|
82
|
+
clientId: options.clientId,
|
|
83
|
+
protocolVersion: 5
|
|
84
|
+
});
|
|
85
|
+
state = connected(client);
|
|
86
|
+
},
|
|
87
|
+
/**
|
|
88
|
+
* Disconnects from the MQTT broker.
|
|
89
|
+
*/
|
|
90
|
+
stop() {
|
|
91
|
+
if (!state.connected()) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
state.disconnect();
|
|
95
|
+
state = idle();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
package/src/postgresSink.js
CHANGED
|
@@ -25,6 +25,35 @@ import pg from 'pg';
|
|
|
25
25
|
* @param {Array<string>} [options.update] - Columns for DO UPDATE SET clause
|
|
26
26
|
* @returns {object} Sink with write(records) method
|
|
27
27
|
*/
|
|
28
|
+
/**
|
|
29
|
+
* Removes duplicate records by conflict key within a batch.
|
|
30
|
+
*
|
|
31
|
+
* PostgreSQL rejects INSERT batches where ON CONFLICT would update
|
|
32
|
+
* the same row twice. This keeps the last occurrence for each
|
|
33
|
+
* unique combination of conflict column values.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* deduplicate(
|
|
37
|
+
* [{ machine: 'a', start: 1, name: 'pending' },
|
|
38
|
+
* { machine: 'a', start: 1, name: 'completed' }],
|
|
39
|
+
* ['machine', 'start']
|
|
40
|
+
* );
|
|
41
|
+
* // => [{ machine: 'a', start: 1, name: 'completed' }]
|
|
42
|
+
*
|
|
43
|
+
* @param {Array} records - Array of record objects
|
|
44
|
+
* @param {Array<string>} keys - Conflict column names
|
|
45
|
+
* @returns {Array} Deduplicated records
|
|
46
|
+
*/
|
|
47
|
+
export function deduplicate(records, keys) {
|
|
48
|
+
if (keys.length === 0) return records;
|
|
49
|
+
const seen = new Map();
|
|
50
|
+
for (const record of records) {
|
|
51
|
+
const key = keys.map(k => record[k]).join('\0');
|
|
52
|
+
seen.set(key, record);
|
|
53
|
+
}
|
|
54
|
+
return Array.from(seen.values());
|
|
55
|
+
}
|
|
56
|
+
|
|
28
57
|
/**
|
|
29
58
|
* Builds the ON CONFLICT SQL suffix from options.
|
|
30
59
|
*
|
|
@@ -53,6 +82,7 @@ export default function postgresSink(url, table, columns, options = {}) {
|
|
|
53
82
|
}
|
|
54
83
|
const pool = new pg.Pool({ connectionString: url });
|
|
55
84
|
const suffix = buildSuffix(options);
|
|
85
|
+
const conflict = Array.isArray(options.conflict) ? options.conflict : [];
|
|
56
86
|
return {
|
|
57
87
|
/**
|
|
58
88
|
* Writes records to PostgreSQL table.
|
|
@@ -61,13 +91,14 @@ export default function postgresSink(url, table, columns, options = {}) {
|
|
|
61
91
|
* @returns {Promise} Promise resolving when insert completes
|
|
62
92
|
*/
|
|
63
93
|
write(records) {
|
|
64
|
-
const
|
|
94
|
+
const unique = deduplicate(records, conflict);
|
|
95
|
+
const placeholders = unique.map((_, i) => {
|
|
65
96
|
const offset = i * columns.length;
|
|
66
97
|
const row = columns.map((__, j) => `$${offset + j + 1}`).join(', ');
|
|
67
98
|
return `(${row})`;
|
|
68
99
|
}).join(', ');
|
|
69
100
|
const query = `INSERT INTO ${table} (${columns.join(', ')}) VALUES ${placeholders}${suffix}`;
|
|
70
|
-
const values =
|
|
101
|
+
const values = unique.flatMap((record) => columns.map((col) => record[col]));
|
|
71
102
|
return pool.query(query, values);
|
|
72
103
|
}
|
|
73
104
|
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { describe, it, before, after } from 'mocha';
|
|
3
|
+
import { GenericContainer } from 'testcontainers';
|
|
4
|
+
import mqtt from 'mqtt';
|
|
5
|
+
import mqttSink from '../src/mqttSink.js';
|
|
6
|
+
|
|
7
|
+
describe('mqttSink integration', function() {
|
|
8
|
+
let container;
|
|
9
|
+
let url;
|
|
10
|
+
let subscriber;
|
|
11
|
+
|
|
12
|
+
before(async function() {
|
|
13
|
+
this.timeout(120000);
|
|
14
|
+
container = await new GenericContainer('eclipse-mosquitto:2')
|
|
15
|
+
.withExposedPorts(1883)
|
|
16
|
+
.withCommand(['mosquitto', '-c', '/mosquitto-no-auth.conf'])
|
|
17
|
+
.withStartupTimeout(30000)
|
|
18
|
+
.start();
|
|
19
|
+
const host = container.getHost();
|
|
20
|
+
const port = container.getMappedPort(1883);
|
|
21
|
+
url = `mqtt://${host}:${port}`;
|
|
22
|
+
subscriber = mqtt.connect(url);
|
|
23
|
+
await new Promise((resolve, reject) => {
|
|
24
|
+
subscriber.on('connect', resolve);
|
|
25
|
+
subscriber.on('error', reject);
|
|
26
|
+
setTimeout(() => reject(new Error('MQTT connection timeout')), 10000);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
after(async function() {
|
|
31
|
+
this.timeout(30000);
|
|
32
|
+
if (subscriber) {
|
|
33
|
+
subscriber.end();
|
|
34
|
+
}
|
|
35
|
+
if (container) {
|
|
36
|
+
await container.stop();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('publishes records that subscriber receives', async function() {
|
|
41
|
+
this.timeout(10000);
|
|
42
|
+
const received = [];
|
|
43
|
+
const topic = `sink/${Math.random().toString(36).slice(2)}`;
|
|
44
|
+
subscriber.subscribe(topic);
|
|
45
|
+
subscriber.on('message', (t, payload) => {
|
|
46
|
+
received.push({ topic: t, payload: payload.toString() });
|
|
47
|
+
});
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
49
|
+
const sink = mqttSink(url);
|
|
50
|
+
sink.start();
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
52
|
+
const value = `${Math.random()}`;
|
|
53
|
+
sink.write([{ topic, payload: value }]);
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
55
|
+
sink.stop();
|
|
56
|
+
assert.strictEqual(received.length >= 1, true, 'Subscriber should receive published record');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('publishes unicode payload', async function() {
|
|
60
|
+
this.timeout(10000);
|
|
61
|
+
const received = [];
|
|
62
|
+
const topic = `юникод/${Math.random().toString(36).slice(2)}`;
|
|
63
|
+
subscriber.subscribe(topic);
|
|
64
|
+
subscriber.on('message', (t, payload) => {
|
|
65
|
+
if (t === topic) {
|
|
66
|
+
received.push(payload.toString());
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
70
|
+
const sink = mqttSink(url);
|
|
71
|
+
sink.start();
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
73
|
+
const text = `Данные температуры ${Math.random()}`;
|
|
74
|
+
sink.write([{ topic, payload: text }]);
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
76
|
+
sink.stop();
|
|
77
|
+
assert.strictEqual(received.length >= 1, true, 'Subscriber should receive unicode payload');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('does not publish after stop', async function() {
|
|
81
|
+
this.timeout(10000);
|
|
82
|
+
const received = [];
|
|
83
|
+
const topic = `stopped/${Math.random().toString(36).slice(2)}`;
|
|
84
|
+
subscriber.subscribe(topic);
|
|
85
|
+
subscriber.on('message', (t, payload) => {
|
|
86
|
+
if (t === topic) {
|
|
87
|
+
received.push(payload.toString());
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
91
|
+
const sink = mqttSink(url);
|
|
92
|
+
sink.start();
|
|
93
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
94
|
+
sink.stop();
|
|
95
|
+
assert.throws(
|
|
96
|
+
() => sink.write([{ topic, payload: `${Math.random()}` }]),
|
|
97
|
+
/not connected/,
|
|
98
|
+
'Should throw when writing after stop'
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { describe, it } from 'mocha';
|
|
3
|
+
import modbusSource from '../src/modbusSource.js';
|
|
4
|
+
import fakeClock from './fakeClock.js';
|
|
5
|
+
|
|
6
|
+
describe('modbusSource', () => {
|
|
7
|
+
it('throws on empty host', () => {
|
|
8
|
+
const collector = { accept: () => {} };
|
|
9
|
+
const clk = fakeClock(Math.floor(Math.random() * 10000));
|
|
10
|
+
assert.throws(
|
|
11
|
+
() => modbusSource('', 502, 4000, 14, 5, collector, clk),
|
|
12
|
+
/Host must be a non-empty string/,
|
|
13
|
+
'Should reject empty host'
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('throws on missing host', () => {
|
|
18
|
+
const collector = { accept: () => {} };
|
|
19
|
+
const clk = fakeClock(Math.floor(Math.random() * 10000));
|
|
20
|
+
assert.throws(
|
|
21
|
+
() => modbusSource(null, 502, 4000, 14, 5, collector, clk),
|
|
22
|
+
/Host must be a non-empty string/,
|
|
23
|
+
'Should reject null host'
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('throws on invalid port', () => {
|
|
28
|
+
const collector = { accept: () => {} };
|
|
29
|
+
const clk = fakeClock(Math.floor(Math.random() * 10000));
|
|
30
|
+
assert.throws(
|
|
31
|
+
() => modbusSource('192.168.2.148', -1, 4000, 14, 5, collector, clk),
|
|
32
|
+
/Port must be a positive number/,
|
|
33
|
+
'Should reject negative port'
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('throws on negative address', () => {
|
|
38
|
+
const collector = { accept: () => {} };
|
|
39
|
+
const clk = fakeClock(Math.floor(Math.random() * 10000));
|
|
40
|
+
assert.throws(
|
|
41
|
+
() => modbusSource('192.168.2.148', 502, -1, 14, 5, collector, clk),
|
|
42
|
+
/Address must be a non-negative number/,
|
|
43
|
+
'Should reject negative address'
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('throws on invalid count', () => {
|
|
48
|
+
const collector = { accept: () => {} };
|
|
49
|
+
const clk = fakeClock(Math.floor(Math.random() * 10000));
|
|
50
|
+
assert.throws(
|
|
51
|
+
() => modbusSource('192.168.2.148', 502, 4000, 0, 5, collector, clk),
|
|
52
|
+
/Count must be a positive number/,
|
|
53
|
+
'Should reject zero count'
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('throws on missing collector', () => {
|
|
58
|
+
const clk = fakeClock(Math.floor(Math.random() * 10000));
|
|
59
|
+
assert.throws(
|
|
60
|
+
() => modbusSource('192.168.2.148', 502, 4000, 14, 5, null, clk),
|
|
61
|
+
/Collector must have an accept\(\) method/,
|
|
62
|
+
'Should reject null collector'
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('throws on missing clock', () => {
|
|
67
|
+
const collector = { accept: () => {} };
|
|
68
|
+
assert.throws(
|
|
69
|
+
() => modbusSource('192.168.2.148', 502, 4000, 14, 5, collector, null),
|
|
70
|
+
/Clock must have a millis\(\) method/,
|
|
71
|
+
'Should reject null clock'
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('creates source with start and stop methods', () => {
|
|
76
|
+
const collector = { accept: () => {} };
|
|
77
|
+
const clk = fakeClock(Math.floor(Math.random() * 10000));
|
|
78
|
+
const source = modbusSource(`\u00e9${Math.random()}`, 502, 4000, 14, 5, collector, clk);
|
|
79
|
+
assert.strictEqual(typeof source.start, 'function', 'Should have start method');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('creates source with stop method', () => {
|
|
83
|
+
const collector = { accept: () => {} };
|
|
84
|
+
const clk = fakeClock(Math.floor(Math.random() * 10000));
|
|
85
|
+
const source = modbusSource(`\u4e2d${Math.random()}`, 502, 4000, 14, 5, collector, clk);
|
|
86
|
+
assert.strictEqual(typeof source.stop, 'function', 'Should have stop method');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('stops without error when not started', () => {
|
|
90
|
+
const collector = { accept: () => {} };
|
|
91
|
+
const clk = fakeClock(Math.floor(Math.random() * 10000));
|
|
92
|
+
const source = modbusSource(`\u3042${Math.random()}`, 502, 4000, 14, 5, collector, clk);
|
|
93
|
+
source.stop();
|
|
94
|
+
assert.strictEqual(true, true, 'Should not throw when stopping without start');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { describe, it } from 'mocha';
|
|
3
|
+
import mqttSink from '../src/mqttSink.js';
|
|
4
|
+
|
|
5
|
+
describe('mqttSink', () => {
|
|
6
|
+
it('throws on empty url', () => {
|
|
7
|
+
assert.throws(
|
|
8
|
+
() => mqttSink(''),
|
|
9
|
+
/URL must be a non-empty string/,
|
|
10
|
+
'Should reject empty URL'
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('throws on missing url', () => {
|
|
15
|
+
assert.throws(
|
|
16
|
+
() => mqttSink(null),
|
|
17
|
+
/URL must be a non-empty string/,
|
|
18
|
+
'Should reject null URL'
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('creates sink with write method', () => {
|
|
23
|
+
const sink = mqttSink(`mqtt://\u00e9${Math.random()}`);
|
|
24
|
+
assert.strictEqual(typeof sink.write, 'function', 'Should have write method');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('creates sink with start method', () => {
|
|
28
|
+
const sink = mqttSink(`mqtt://\u4e2d${Math.random()}`);
|
|
29
|
+
assert.strictEqual(typeof sink.start, 'function', 'Should have start method');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('creates sink with stop method', () => {
|
|
33
|
+
const sink = mqttSink(`mqtt://\u3042${Math.random()}`);
|
|
34
|
+
assert.strictEqual(typeof sink.stop, 'function', 'Should have stop method');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('throws on write when not connected', () => {
|
|
38
|
+
const sink = mqttSink(`mqtt://\u00df${Math.random()}`);
|
|
39
|
+
assert.throws(
|
|
40
|
+
() => sink.write([{ topic: `\u00e9${Math.random()}`, payload: `${Math.random()}` }]),
|
|
41
|
+
/Sink is not connected/,
|
|
42
|
+
'Should reject write when not connected'
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('stops without error when not started', () => {
|
|
47
|
+
const sink = mqttSink(`mqtt://\u0420${Math.random()}`);
|
|
48
|
+
sink.stop();
|
|
49
|
+
assert.strictEqual(true, true, 'Should not throw when stopping without start');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert';
|
|
2
2
|
import { describe, it } from 'mocha';
|
|
3
|
-
import postgresSink from '../src/postgresSink.js';
|
|
3
|
+
import postgresSink, { deduplicate } from '../src/postgresSink.js';
|
|
4
4
|
|
|
5
5
|
describe('postgresSink', () => {
|
|
6
6
|
it('throws on missing url', () => {
|
|
@@ -80,4 +80,32 @@ describe('postgresSink', () => {
|
|
|
80
80
|
{ conflict: ['machine', 'start_time'], update: ['name', 'end_time', 'duration'] });
|
|
81
81
|
assert.strictEqual(typeof sink.write, 'function', 'Should have write method with update option');
|
|
82
82
|
});
|
|
83
|
+
|
|
84
|
+
it('deduplicates batch by conflict columns keeping last occurrence', () => {
|
|
85
|
+
const records = [
|
|
86
|
+
{ machine: 'ü-1', name: 'pending', start_time: '2024-01-01' },
|
|
87
|
+
{ machine: 'ü-1', name: 'completed', start_time: '2024-01-01' },
|
|
88
|
+
{ machine: 'ö-2', name: 'pending', start_time: '2024-01-02' }
|
|
89
|
+
];
|
|
90
|
+
const result = deduplicate(records, ['machine', 'start_time']);
|
|
91
|
+
assert.strictEqual(result.length, 2, 'Should remove duplicate conflict key');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('keeps last record when conflict columns match', () => {
|
|
95
|
+
const records = [
|
|
96
|
+
{ machine: 'ü-1', name: 'pending', start_time: '2024-01-01' },
|
|
97
|
+
{ machine: 'ü-1', name: 'completed', start_time: '2024-01-01' }
|
|
98
|
+
];
|
|
99
|
+
const result = deduplicate(records, ['machine', 'start_time']);
|
|
100
|
+
assert.strictEqual(result[0].name, 'completed', 'Should keep the last occurrence');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns records unchanged when no conflict columns', () => {
|
|
104
|
+
const records = [
|
|
105
|
+
{ machine: 'ä-1', name: 'à', start_time: '2024-01-01' },
|
|
106
|
+
{ machine: 'ä-1', name: 'à', start_time: '2024-01-01' }
|
|
107
|
+
];
|
|
108
|
+
const result = deduplicate(records, []);
|
|
109
|
+
assert.strictEqual(result.length, 2, 'Should not deduplicate without conflict columns');
|
|
110
|
+
});
|
|
83
111
|
});
|