@yarkivaev/source-to-sink 1.3.5 → 1.3.7
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/package.json +1 -1
- package/src/follow.js +41 -0
- package/src/modbusSource.js +37 -14
- package/src/pollingSource.js +8 -5
- package/src/postgresSink.js +6 -2
- package/src/stompSource.js +27 -21
- package/test/test_follow.js +35 -0
- package/test/test_modbusSource.js +43 -0
- package/test/test_pollingSource.js +20 -0
- package/test/test_postgresSink.js +22 -0
package/package.json
CHANGED
package/src/follow.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Continues a delivery chain after a rejected prior step.
|
|
3
|
+
*
|
|
4
|
+
* @param {Promise} pending - prior chain
|
|
5
|
+
* @returns {Promise} settled chain
|
|
6
|
+
*/
|
|
7
|
+
function resume(pending) {
|
|
8
|
+
return pending.then((value) => {
|
|
9
|
+
return value;
|
|
10
|
+
}, (error) => {
|
|
11
|
+
return error;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Chains one arrival: read the body, then start deliver.
|
|
17
|
+
* Serial waits for deliver. Otherwise persist may overlap after enqueue.
|
|
18
|
+
*
|
|
19
|
+
* @param {Promise} pending - prior arrival
|
|
20
|
+
* @param {Function} read - returns a promise for the body
|
|
21
|
+
* @param {Function} deliver - starts persist for one body
|
|
22
|
+
* @param {boolean} serial - wait for persist before the next arrival
|
|
23
|
+
* @returns {Promise} chain
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* let pending = Promise.resolve();
|
|
27
|
+
* pending = follow(pending, () => Promise.resolve('a'), write, false);
|
|
28
|
+
*/
|
|
29
|
+
export default function follow(pending, read, deliver, serial) {
|
|
30
|
+
return resume(pending).then(async () => {
|
|
31
|
+
const body = await read();
|
|
32
|
+
const run = deliver(body);
|
|
33
|
+
if (serial) {
|
|
34
|
+
return run;
|
|
35
|
+
}
|
|
36
|
+
void run.catch((error) => {
|
|
37
|
+
return error;
|
|
38
|
+
});
|
|
39
|
+
return undefined;
|
|
40
|
+
});
|
|
41
|
+
}
|
package/src/modbusSource.js
CHANGED
|
@@ -85,6 +85,28 @@ function connected(client) {
|
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Destroys a Modbus client without throwing if already closed.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} client - ModbusRTU client
|
|
92
|
+
*/
|
|
93
|
+
function drop(client) {
|
|
94
|
+
if (client && typeof client.destroy === 'function') {
|
|
95
|
+
client.destroy(() => {
|
|
96
|
+
return undefined;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Builds a ModbusRTU client for one connect attempt.
|
|
103
|
+
*
|
|
104
|
+
* @returns {object} Fresh ModbusRTU client
|
|
105
|
+
*/
|
|
106
|
+
function create() {
|
|
107
|
+
return new ModbusRTU();
|
|
108
|
+
}
|
|
109
|
+
|
|
88
110
|
/**
|
|
89
111
|
* Starts one connect attempt and ignores results after stop().
|
|
90
112
|
*
|
|
@@ -93,9 +115,11 @@ function connected(client) {
|
|
|
93
115
|
function attempt(bag) {
|
|
94
116
|
const token = bag.epoch;
|
|
95
117
|
bag.state = opening();
|
|
118
|
+
drop(bag.client);
|
|
119
|
+
bag.client = bag.create();
|
|
96
120
|
bag.connect(bag.client).then(() => {
|
|
97
121
|
if (token !== bag.epoch) {
|
|
98
|
-
bag.client
|
|
122
|
+
drop(bag.client);
|
|
99
123
|
return;
|
|
100
124
|
}
|
|
101
125
|
bag.delay = bag.interval;
|
|
@@ -105,6 +129,7 @@ function attempt(bag) {
|
|
|
105
129
|
if (token !== bag.epoch) {
|
|
106
130
|
return;
|
|
107
131
|
}
|
|
132
|
+
drop(bag.client);
|
|
108
133
|
bag.log.error(`Connection to ${bag.target} failed, retrying in ${bag.delay}s: ${err.message}`);
|
|
109
134
|
const handle = setTimeout(() => {
|
|
110
135
|
attempt(bag);
|
|
@@ -121,19 +146,20 @@ function attempt(bag) {
|
|
|
121
146
|
* @returns {object} Source with start() and stop() methods
|
|
122
147
|
*/
|
|
123
148
|
function modbusPollingSource(options) {
|
|
124
|
-
const { address, count, interval, collector, clk
|
|
125
|
-
async function fetch() {
|
|
126
|
-
const result = await client.readHoldingRegisters(address, count);
|
|
127
|
-
return [result.data];
|
|
128
|
-
}
|
|
149
|
+
const { address, count, interval, collector, clk } = options;
|
|
129
150
|
const bag = {
|
|
130
151
|
...options,
|
|
152
|
+
client: undefined,
|
|
131
153
|
state: idle(),
|
|
132
154
|
epoch: 0,
|
|
133
155
|
maxDelay: 300,
|
|
134
|
-
source: pollingSource(fetch, interval, collector, clk),
|
|
135
156
|
delay: interval
|
|
136
157
|
};
|
|
158
|
+
async function fetch() {
|
|
159
|
+
const result = await bag.client.readHoldingRegisters(address, count);
|
|
160
|
+
return [result.data];
|
|
161
|
+
}
|
|
162
|
+
bag.source = pollingSource(fetch, interval, collector, clk);
|
|
137
163
|
return {
|
|
138
164
|
/**
|
|
139
165
|
* Connects to the Modbus device and starts polling.
|
|
@@ -153,9 +179,8 @@ function modbusPollingSource(options) {
|
|
|
153
179
|
if (bag.state.retrying()) {
|
|
154
180
|
bag.state.cancel();
|
|
155
181
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
182
|
+
drop(bag.client);
|
|
183
|
+
bag.client = undefined;
|
|
159
184
|
bag.state = idle();
|
|
160
185
|
}
|
|
161
186
|
};
|
|
@@ -195,11 +220,11 @@ export default function modbusSource(host, port, address, count, interval, colle
|
|
|
195
220
|
if (typeof count !== 'number' || count <= 0) {
|
|
196
221
|
throw new Error(`Count must be a positive number, got: ${count}`);
|
|
197
222
|
}
|
|
198
|
-
const client = new ModbusRTU();
|
|
199
223
|
function connect(modbusClient) {
|
|
200
224
|
return modbusClient.connectTCP(host, { port });
|
|
201
225
|
}
|
|
202
226
|
return modbusPollingSource({
|
|
227
|
+
create,
|
|
203
228
|
connect,
|
|
204
229
|
target: `${host}:${port}`,
|
|
205
230
|
address,
|
|
@@ -207,7 +232,6 @@ export default function modbusSource(host, port, address, count, interval, colle
|
|
|
207
232
|
interval,
|
|
208
233
|
collector,
|
|
209
234
|
clk,
|
|
210
|
-
client,
|
|
211
235
|
log
|
|
212
236
|
});
|
|
213
237
|
}
|
|
@@ -248,7 +272,6 @@ export function modbusRtuSource(path, serial, address, count, interval, collecto
|
|
|
248
272
|
if (typeof count !== 'number' || count <= 0) {
|
|
249
273
|
throw new Error(`Count must be a positive number, got: ${count}`);
|
|
250
274
|
}
|
|
251
|
-
const client = new ModbusRTU();
|
|
252
275
|
const slaveId = serial.slaveId || 1;
|
|
253
276
|
const dataBits = serial.dataBits || 8;
|
|
254
277
|
const stopBits = serial.stopBits || 1;
|
|
@@ -264,6 +287,7 @@ export function modbusRtuSource(path, serial, address, count, interval, collecto
|
|
|
264
287
|
});
|
|
265
288
|
}
|
|
266
289
|
return modbusPollingSource({
|
|
290
|
+
create,
|
|
267
291
|
connect,
|
|
268
292
|
target: path,
|
|
269
293
|
address,
|
|
@@ -271,7 +295,6 @@ export function modbusRtuSource(path, serial, address, count, interval, collecto
|
|
|
271
295
|
interval,
|
|
272
296
|
collector,
|
|
273
297
|
clk,
|
|
274
|
-
client,
|
|
275
298
|
log
|
|
276
299
|
});
|
|
277
300
|
}
|
package/src/pollingSource.js
CHANGED
|
@@ -64,14 +64,17 @@ export default function pollingSource(fetch, interval, collector, clk) {
|
|
|
64
64
|
}
|
|
65
65
|
let state = idle();
|
|
66
66
|
let since = clk.millis();
|
|
67
|
-
|
|
67
|
+
function poll() {
|
|
68
68
|
const from = since;
|
|
69
69
|
const until = clk.millis();
|
|
70
70
|
since = until;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
Promise.resolve(fetch(from, until)).then((result) => {
|
|
72
|
+
for (const entry of result) {
|
|
73
|
+
collector.accept(entry);
|
|
74
|
+
}
|
|
75
|
+
}).catch(() => {
|
|
76
|
+
return undefined;
|
|
77
|
+
});
|
|
75
78
|
}
|
|
76
79
|
return {
|
|
77
80
|
/**
|
package/src/postgresSink.js
CHANGED
|
@@ -32,7 +32,7 @@ export function deduplicate(records, keys) {
|
|
|
32
32
|
/**
|
|
33
33
|
* Builds the ON CONFLICT SQL suffix from options.
|
|
34
34
|
*
|
|
35
|
-
* @param {object} options - Sink options with conflict and
|
|
35
|
+
* @param {object} options - Sink options with conflict, update, and optional updateWhere
|
|
36
36
|
* @returns {string} SQL suffix or empty string
|
|
37
37
|
*/
|
|
38
38
|
function buildSuffix(options) {
|
|
@@ -40,7 +40,10 @@ function buildSuffix(options) {
|
|
|
40
40
|
const cols = options.conflict.join(', ');
|
|
41
41
|
if (Array.isArray(options.update) && options.update.length > 0) {
|
|
42
42
|
const sets = options.update.map((col) => {return `${col} = EXCLUDED.${col}`}).join(', ');
|
|
43
|
-
|
|
43
|
+
const guard = typeof options.updateWhere === 'string' && options.updateWhere.length > 0
|
|
44
|
+
? ` WHERE ${options.updateWhere}`
|
|
45
|
+
: '';
|
|
46
|
+
return ` ON CONFLICT (${cols}) DO UPDATE SET ${sets}${guard}`;
|
|
44
47
|
}
|
|
45
48
|
return ` ON CONFLICT (${cols}) DO NOTHING`;
|
|
46
49
|
}
|
|
@@ -67,6 +70,7 @@ function buildSuffix(options) {
|
|
|
67
70
|
* @param {object} [options] - Optional configuration
|
|
68
71
|
* @param {Array<string>} [options.conflict] - Columns for ON CONFLICT clause
|
|
69
72
|
* @param {Array<string>} [options.update] - Columns for DO UPDATE SET clause
|
|
73
|
+
* @param {string} [options.updateWhere] - Optional WHERE guard on DO UPDATE
|
|
70
74
|
* @param {object} [options.pool] - Existing pg Pool to reuse
|
|
71
75
|
* @returns {object} Sink with write(records, client?) method
|
|
72
76
|
*/
|
package/src/stompSource.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import stompit from 'stompit';
|
|
2
|
+
import follow from './follow.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Idle state for STOMP source.
|
|
@@ -90,41 +91,46 @@ async function deliver(ctx, body) {
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
/**
|
|
93
|
-
*
|
|
94
|
+
* Reads one STOMP frame body as text.
|
|
94
95
|
*
|
|
95
|
-
* @param {
|
|
96
|
-
* @returns {
|
|
96
|
+
* @param {object} message - STOMP frame
|
|
97
|
+
* @returns {Promise<string>} UTF-8 body
|
|
97
98
|
*/
|
|
98
|
-
function
|
|
99
|
-
return
|
|
99
|
+
function read(message) {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
message.readString('utf-8', (error, body) => {
|
|
102
|
+
if (error) {
|
|
103
|
+
reject(error);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
resolve(body.toString());
|
|
107
|
+
});
|
|
108
|
+
});
|
|
100
109
|
}
|
|
101
110
|
|
|
102
111
|
/**
|
|
103
112
|
* Subscribes a channel and routes frames to the collector.
|
|
113
|
+
* Arrivals stay in subscribe order through follow, even when persist overlaps.
|
|
104
114
|
*
|
|
105
115
|
* @param {object} channel - STOMP channel
|
|
106
116
|
* @param {string} destination - queue destination
|
|
107
117
|
* @param {object} collector - message collector
|
|
108
|
-
* @param {boolean} serial -
|
|
118
|
+
* @param {boolean} serial - wait for persist before the next arrival
|
|
109
119
|
* @param {boolean} manualAck - collector settles frames
|
|
110
120
|
* @returns {undefined}
|
|
111
121
|
*/
|
|
112
122
|
function bind(channel, destination, collector, serial, manualAck) {
|
|
113
123
|
let pending = Promise.resolve();
|
|
114
124
|
channel.subscribe({ destination, ack: 'client-individual' }, (err, message) => {
|
|
115
|
-
if (err) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
} else {
|
|
125
|
-
void work().catch(ignoreDelivery);
|
|
126
|
-
}
|
|
127
|
-
});
|
|
125
|
+
if (err) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const ctx = { channel, message, destination, collector, manualAck };
|
|
129
|
+
pending = follow(pending, () => {
|
|
130
|
+
return read(message);
|
|
131
|
+
}, (body) => {
|
|
132
|
+
return deliver(ctx, body);
|
|
133
|
+
}, serial);
|
|
128
134
|
});
|
|
129
135
|
}
|
|
130
136
|
|
|
@@ -132,8 +138,8 @@ function bind(channel, destination, collector, serial, manualAck) {
|
|
|
132
138
|
* STOMP subscription source for streaming messages to a collector.
|
|
133
139
|
*
|
|
134
140
|
* Messages are `{destination, payload, settle}`. Default ack-after-accept;
|
|
135
|
-
* with `manualAck` the collector owns settle.
|
|
136
|
-
*
|
|
141
|
+
* with `manualAck` the collector owns settle. Arrivals enqueue in
|
|
142
|
+
* subscribe order. With `serial: false` persist may overlap after enqueue.
|
|
137
143
|
*
|
|
138
144
|
* @param {string} url - STOMP broker URL
|
|
139
145
|
* @param {string} destination - STOMP destination
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { describe, it } from 'mocha';
|
|
3
|
+
import follow from '../src/follow.js';
|
|
4
|
+
|
|
5
|
+
describe('follow', () => {
|
|
6
|
+
it('accepts in subscribe order when the first body is slower', async () => {
|
|
7
|
+
const order = [];
|
|
8
|
+
const first = `ä-${Math.random().toString(16).slice(2)}`;
|
|
9
|
+
const second = `ö-${Math.random().toString(16).slice(2)}`;
|
|
10
|
+
const pause = (ms) => {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
setTimeout(resolve, ms);
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
const deliver = (body) => {
|
|
16
|
+
order.push(body);
|
|
17
|
+
return Promise.resolve();
|
|
18
|
+
};
|
|
19
|
+
let pending = Promise.resolve();
|
|
20
|
+
pending = follow(pending, () => {
|
|
21
|
+
return pause(40).then(() => {
|
|
22
|
+
return first;
|
|
23
|
+
});
|
|
24
|
+
}, deliver, false);
|
|
25
|
+
pending = follow(pending, () => {
|
|
26
|
+
return Promise.resolve(second);
|
|
27
|
+
}, deliver, false);
|
|
28
|
+
await pending;
|
|
29
|
+
assert.deepStrictEqual(
|
|
30
|
+
order,
|
|
31
|
+
[first, second],
|
|
32
|
+
'a slower first body reordered accept ahead of subscribe'
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -59,6 +59,27 @@ function settle(ms) {
|
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Collects unhandledRejection events until stop().
|
|
64
|
+
*
|
|
65
|
+
* @returns {object} Collector with seen() and stop()
|
|
66
|
+
*/
|
|
67
|
+
function watchRejects() {
|
|
68
|
+
const seen = [];
|
|
69
|
+
function onReject(err) {
|
|
70
|
+
seen.push(err);
|
|
71
|
+
}
|
|
72
|
+
process.on('unhandledRejection', onReject);
|
|
73
|
+
return {
|
|
74
|
+
seen() {
|
|
75
|
+
return seen.length;
|
|
76
|
+
},
|
|
77
|
+
stop() {
|
|
78
|
+
process.off('unhandledRejection', onReject);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
62
83
|
/**
|
|
63
84
|
* Waits until probe is true or the timeout elapses.
|
|
64
85
|
*
|
|
@@ -200,6 +221,28 @@ describe('modbusSource', () => {
|
|
|
200
221
|
assert.strictEqual(true, true, 'Should not crash on unreachable host');
|
|
201
222
|
});
|
|
202
223
|
|
|
224
|
+
it('does not emit unhandled rejection after connection retry', async function() {
|
|
225
|
+
this.timeout(4000);
|
|
226
|
+
const watch = watchRejects();
|
|
227
|
+
const log = { error() {} };
|
|
228
|
+
const port = 65000 + Math.floor(Math.random() * 500);
|
|
229
|
+
const source = modbusSource(
|
|
230
|
+
'127.0.0.1',
|
|
231
|
+
port,
|
|
232
|
+
4000,
|
|
233
|
+
14,
|
|
234
|
+
0.05,
|
|
235
|
+
{ accept() {} },
|
|
236
|
+
fakeClock(1 + Math.floor(Math.random() * 100)),
|
|
237
|
+
log
|
|
238
|
+
);
|
|
239
|
+
source.start();
|
|
240
|
+
await settle(400);
|
|
241
|
+
source.stop();
|
|
242
|
+
watch.stop();
|
|
243
|
+
assert.strictEqual(watch.seen(), 0, 'retry leaked an unhandled rejection');
|
|
244
|
+
});
|
|
245
|
+
|
|
203
246
|
it('does not open a second TCP socket when start is called twice', async function() {
|
|
204
247
|
const gate = await countingListen();
|
|
205
248
|
const source = modbusSource(
|
|
@@ -91,6 +91,26 @@ describe('pollingSource', () => {
|
|
|
91
91
|
assert.strictEqual(count, before, 'Should stop polling after stop');
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
it('does not emit unhandled rejection when fetch rejects', async () => {
|
|
95
|
+
const seen = [];
|
|
96
|
+
function onReject(err) {
|
|
97
|
+
seen.push(err);
|
|
98
|
+
}
|
|
99
|
+
process.on('unhandledRejection', onReject);
|
|
100
|
+
const label = `\u00e9${Math.random()}`;
|
|
101
|
+
const fetch = () => {
|
|
102
|
+
return Promise.reject(new Error(label));
|
|
103
|
+
};
|
|
104
|
+
const source = pollingSource(fetch, 0.03, { accept() {} }, fakeClock(1 + Math.floor(Math.random() * 100)));
|
|
105
|
+
source.start();
|
|
106
|
+
await new Promise((resolve) => {
|
|
107
|
+
setTimeout(resolve, 80);
|
|
108
|
+
});
|
|
109
|
+
source.stop();
|
|
110
|
+
process.off('unhandledRejection', onReject);
|
|
111
|
+
assert.strictEqual(seen.length, 0, 'poll rejection leaked as unhandled');
|
|
112
|
+
});
|
|
113
|
+
|
|
94
114
|
it('throws on missing fetch', () => {
|
|
95
115
|
const collector = { accept: () => {} };
|
|
96
116
|
const clk = fakeClock(0);
|
|
@@ -122,6 +122,28 @@ describe('postgresSink', () => {
|
|
|
122
122
|
assert.strictEqual(queries.length, 1, 'injected pool was not used for write');
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
it('appends updateWhere to the conflict update', async () => {
|
|
126
|
+
const queries = [];
|
|
127
|
+
const pool = {
|
|
128
|
+
query(sql) {
|
|
129
|
+
queries.push(sql);
|
|
130
|
+
return Promise.resolve({ rowCount: 1 });
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const sink = postgresSink('postgresql://localhost:5432/db', 'segments',
|
|
134
|
+
['machine', 'duration'], {
|
|
135
|
+
pool,
|
|
136
|
+
conflict: ['machine'],
|
|
137
|
+
update: ['duration'],
|
|
138
|
+
updateWhere: 'segments.duration = 0 OR EXCLUDED.duration > 0'
|
|
139
|
+
});
|
|
140
|
+
await sink.write([{ machine: `ä-${Math.random()}`, duration: 0 }]);
|
|
141
|
+
assert.ok(
|
|
142
|
+
queries[0].includes('WHERE segments.duration = 0 OR EXCLUDED.duration > 0'),
|
|
143
|
+
'conflict update did not keep the monotonic duration guard'
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
125
147
|
it('routes write through an explicit client when provided', async () => {
|
|
126
148
|
const pool = { query() { return Promise.reject(new Error('pool must not be used')); } };
|
|
127
149
|
const clientQueries = [];
|