@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 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",
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
+ }
@@ -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
+ }
@@ -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 placeholders = records.map((_, i) => {
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 = records.flatMap((record) => columns.map((col) => record[col]));
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
  });