@yarkivaev/scada 1.5.0 → 2.3.45

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.
Files changed (190) hide show
  1. package/README.md +196 -33
  2. package/db/migrations/C0001__central_drop_metrics.sql +1 -0
  3. package/db/migrations/C0002__central_revoke_delete.sql +12 -0
  4. package/db/migrations/C0003__central_operators.sql +7 -0
  5. package/db/migrations/C0004__central_operations_grants.sql +2 -0
  6. package/db/migrations/C0005__central_operators_grants.sql +1 -0
  7. package/db/migrations/C0006__central_operators_registration.sql +16 -0
  8. package/db/migrations/E0001__edge_metrics.sql +8 -0
  9. package/db/migrations/E0002__edge_retention_delete.sql +8 -0
  10. package/db/migrations/E0003__edge_operations_grants.sql +2 -0
  11. package/db/migrations/V0001__baseline.sql +25 -0
  12. package/db/migrations/V0002__sink_extension_tables.sql +6 -0
  13. package/db/migrations/V0003__operations.sql +10 -0
  14. package/db/migrations/V0004__ingest_checkpoints.sql +6 -0
  15. package/db/migrations/V0005__user_decisions_operator.sql +2 -0
  16. package/index.js +75 -46
  17. package/package.json +28 -9
  18. package/src/application/bindSilentStreams.js +20 -0
  19. package/src/application/edgeOperatorCatalog.js +45 -0
  20. package/src/application/export/exportJob.js +118 -0
  21. package/src/application/export/exportQuery.js +32 -0
  22. package/src/application/export/exportSink.js +25 -0
  23. package/src/application/export/exportStream.js +33 -0
  24. package/src/application/machineInPlant.js +20 -0
  25. package/src/application/metricsPlant.js +35 -0
  26. package/src/application/plantApi.js +49 -0
  27. package/src/application/plantOperations.js +19 -0
  28. package/src/application/plantServer.js +107 -0
  29. package/src/application/shopWithTimeline.js +112 -0
  30. package/src/application/siteOperatorCatalog.js +78 -0
  31. package/src/application/siteServer.js +191 -0
  32. package/src/application/supervisorSink.js +97 -0
  33. package/src/application/timelineOperatorFromEnv.js +19 -0
  34. package/src/bin/supervisor-sink.js +28 -0
  35. package/src/{alert.js → domain/alerting/alert.js} +2 -2
  36. package/src/{alerts.js → domain/alerting/alerts.js} +3 -3
  37. package/src/domain/alerting/ingest.js +18 -0
  38. package/src/domain/ingest/ingestCursor.js +26 -0
  39. package/src/domain/operation/operation.js +23 -0
  40. package/src/domain/operation/operations.js +81 -0
  41. package/src/domain/operator/operator.js +23 -0
  42. package/src/domain/plant/machine.js +32 -0
  43. package/src/domain/plant/plant.js +20 -0
  44. package/src/domain/plant/shop.js +24 -0
  45. package/src/domain/segment/dispatch.js +31 -0
  46. package/src/domain/segment/normalize.js +31 -0
  47. package/src/domain/segment/silenceBudget.js +16 -0
  48. package/src/{initialized.js → domain/shared/initialized.js} +3 -4
  49. package/src/{pubsub.js → domain/shared/pubsub.js} +2 -3
  50. package/src/domain/timeline/timeline.js +25 -0
  51. package/src/infrastructure/catalog/tag-catalog.json +27 -0
  52. package/src/infrastructure/catalog/tagCatalog.js +65 -0
  53. package/src/infrastructure/client/index.js +20 -0
  54. package/src/infrastructure/client/machineClient.js +159 -0
  55. package/src/infrastructure/client/machineOperationsClient.js +159 -0
  56. package/src/infrastructure/client/scadaClient.js +67 -0
  57. package/src/infrastructure/client/sseConnection.js +42 -0
  58. package/src/infrastructure/http/edge/edgeApi.js +54 -0
  59. package/src/infrastructure/http/edge/httpMetricsRead.js +34 -0
  60. package/src/infrastructure/http/edge/metricsSensor.js +15 -0
  61. package/src/infrastructure/http/edge/parseStateHttpTimeoutMs.js +20 -0
  62. package/src/infrastructure/http/edge/routes/checkpoint/checkpointRoutes.js +79 -0
  63. package/src/infrastructure/http/edge/routes/metrics/metricsBatch.js +59 -0
  64. package/src/infrastructure/http/edge/routes/metrics/metricsCurrent.js +29 -0
  65. package/src/infrastructure/http/edge/routes/metrics/metricsPoll.js +25 -0
  66. package/src/infrastructure/http/edge/routes/metrics/metricsRange.js +26 -0
  67. package/src/infrastructure/http/edge/routes/metrics/metricsRoutes.js +21 -0
  68. package/src/infrastructure/http/edge/routes/retention/retentionRoutes.js +41 -0
  69. package/src/infrastructure/http/edge/startTestEdgeApi.js +39 -0
  70. package/src/infrastructure/http/edge/stateAccess.js +13 -0
  71. package/src/infrastructure/http/edge/stateHttpClient.js +106 -0
  72. package/src/infrastructure/http/edge/stateHttpTimeoutError.js +13 -0
  73. package/src/infrastructure/http/plant/json/decisionJson.js +44 -0
  74. package/src/infrastructure/http/plant/json/operationJson.js +28 -0
  75. package/src/infrastructure/http/plant/json/operatorJson.js +22 -0
  76. package/src/infrastructure/http/plant/json/segmentJson.js +27 -0
  77. package/src/infrastructure/http/plant/operationAudit.js +56 -0
  78. package/src/infrastructure/http/plant/routes/alertRoute.js +93 -0
  79. package/src/infrastructure/http/plant/routes/catalogRoute.js +21 -0
  80. package/src/infrastructure/http/plant/routes/decisionRoute.js +60 -0
  81. package/src/infrastructure/http/plant/routes/machineRoute.js +35 -0
  82. package/src/infrastructure/http/plant/routes/measurementRoute.js +60 -0
  83. package/src/infrastructure/http/plant/routes/operationDrafts.js +79 -0
  84. package/src/infrastructure/http/plant/routes/operationRoute.js +80 -0
  85. package/src/infrastructure/http/plant/routes/operationWrites.js +186 -0
  86. package/src/infrastructure/http/plant/routes/operatorRoute.js +143 -0
  87. package/src/infrastructure/http/plant/routes/simulationRoute.js +61 -0
  88. package/src/infrastructure/http/plant/routes/timelineRoute.js +112 -0
  89. package/src/infrastructure/http/plant/streams/alertStream.js +68 -0
  90. package/src/infrastructure/http/plant/streams/heartbeatStream.js +34 -0
  91. package/src/infrastructure/http/plant/streams/measurementStream.js +90 -0
  92. package/src/infrastructure/http/plant/streams/operationStream.js +42 -0
  93. package/src/infrastructure/http/plant/streams/timelineStream.js +97 -0
  94. package/src/infrastructure/http/plant/timelineOperator.js +87 -0
  95. package/src/infrastructure/ingest/activity/activityTracking.js +74 -0
  96. package/src/infrastructure/ingest/activity/activityTransformer.js +45 -0
  97. package/src/infrastructure/ingest/codecs/alertCodec.js +56 -0
  98. package/src/infrastructure/ingest/codecs/segmentCodec.js +30 -0
  99. package/src/infrastructure/ingest/codecs/userDecisionCodec.js +78 -0
  100. package/src/infrastructure/ingest/cooldown.js +24 -0
  101. package/src/infrastructure/ingest/db/migrate.js +78 -0
  102. package/src/infrastructure/ingest/db/migrationProfile.js +35 -0
  103. package/src/infrastructure/ingest/db/runRetention.js +58 -0
  104. package/src/infrastructure/ingest/ingestCheckpoint.js +71 -0
  105. package/src/infrastructure/ingest/modbus/mx210Tcp.js +81 -0
  106. package/src/infrastructure/ingest/modbus/silentStreams.js +152 -0
  107. package/src/infrastructure/ingest/mqtt/metricsTransformer.js +54 -0
  108. package/src/infrastructure/ingest/mqtt/modbusDeviceSpec.js +112 -0
  109. package/src/infrastructure/ingest/mqtt/modbusMqtt.js +204 -0
  110. package/src/infrastructure/ingest/mqtt/mqttMetrics.js +78 -0
  111. package/src/infrastructure/ingest/parseRequestTimeoutMs.js +20 -0
  112. package/src/infrastructure/ingest/pipelines/alertPipeline.js +48 -0
  113. package/src/infrastructure/ingest/pipelines/decisionPipeline.js +45 -0
  114. package/src/infrastructure/ingest/pipelines/segmentPipeline.js +60 -0
  115. package/src/infrastructure/ingest/processingErrorLog.js +25 -0
  116. package/src/infrastructure/ingest/silentOpenWatch.js +50 -0
  117. package/src/infrastructure/ingest/sinks/alertSink.js +43 -0
  118. package/src/infrastructure/ingest/sinks/closeOrphanOpen.js +32 -0
  119. package/src/infrastructure/ingest/sinks/closeSilentOpen.js +39 -0
  120. package/src/infrastructure/ingest/sinks/retagSink.js +43 -0
  121. package/src/infrastructure/ingest/sinks/userDecisionSink.js +41 -0
  122. package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +94 -0
  123. package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +71 -0
  124. package/src/infrastructure/ingest/telemetry/deliverToMqttRecord.js +19 -0
  125. package/src/infrastructure/ingest/telemetry/streamNameFromTopic.js +21 -0
  126. package/src/infrastructure/messaging/ownership/httpOperations.js +96 -0
  127. package/src/infrastructure/messaging/ownership/httpTimeline.js +99 -0
  128. package/src/infrastructure/messaging/ownership/machineOwners.js +39 -0
  129. package/src/infrastructure/messaging/ownership/ownerTimeline.js +28 -0
  130. package/src/infrastructure/messaging/stomp/alerts/stompAlerts.js +21 -0
  131. package/src/infrastructure/messaging/stomp/alerts/stompAlertsCollection.js +51 -0
  132. package/src/infrastructure/messaging/stomp/alerts/stompAlertsInit.js +24 -0
  133. package/src/infrastructure/messaging/stomp/alerts/stompAlertsLogic.js +51 -0
  134. package/src/infrastructure/messaging/stomp/stompTimelineSegments.js +80 -0
  135. package/src/infrastructure/messaging/stomp/timeline.js +21 -0
  136. package/src/infrastructure/messaging/stomp/userDecisionBody.js +25 -0
  137. package/src/infrastructure/messaging/stomp/userDecisions.js +42 -0
  138. package/src/infrastructure/operators/centralOperators.js +64 -0
  139. package/src/infrastructure/operators/edgeOperators.js +39 -0
  140. package/src/infrastructure/operators/operatorById.js +33 -0
  141. package/src/infrastructure/operators/operatorExtras.js +41 -0
  142. package/src/infrastructure/operators/operators.js +32 -0
  143. package/src/infrastructure/operators/operatorsFromSeed.js +18 -0
  144. package/src/infrastructure/operators/operatorsSync.js +57 -0
  145. package/src/infrastructure/persistence/clickhouse/connection.js +58 -0
  146. package/src/infrastructure/persistence/clickhouse/pollTopicCursors.js +48 -0
  147. package/src/{clickhouseSensor.js → infrastructure/persistence/clickhouse/sensor.js} +9 -27
  148. package/src/infrastructure/persistence/clickhouse/streamHub.js +165 -0
  149. package/src/infrastructure/persistence/memory/alerts.js +21 -0
  150. package/src/infrastructure/persistence/memory/checkpoints.js +98 -0
  151. package/src/infrastructure/persistence/memory/metrics.js +69 -0
  152. package/src/infrastructure/persistence/memory/metricsDisabled.js +19 -0
  153. package/src/infrastructure/persistence/memory/operations.js +87 -0
  154. package/src/infrastructure/persistence/memory/segments.js +83 -0
  155. package/src/infrastructure/persistence/memory/timeline.js +56 -0
  156. package/src/infrastructure/persistence/metricsSensor.js +112 -0
  157. package/src/infrastructure/persistence/pg/alerts.js +25 -0
  158. package/src/infrastructure/persistence/pg/checkpoints.js +115 -0
  159. package/src/infrastructure/persistence/pg/metrics.js +73 -0
  160. package/src/infrastructure/persistence/pg/operations.js +95 -0
  161. package/src/infrastructure/persistence/pg/operators.js +118 -0
  162. package/src/infrastructure/persistence/pg/segments.js +48 -0
  163. package/src/infrastructure/persistence/pg/timeline.js +53 -0
  164. package/src/infrastructure/persistence/pg/userDecisions.js +72 -0
  165. package/src/infrastructure/persistence/postgresPool.js +24 -0
  166. package/src/infrastructure/persistence/stateDataFromMemory.js +28 -0
  167. package/src/infrastructure/persistence/stateDataFromPool.js +18 -0
  168. package/src/infrastructure/sync/operationCodec.js +75 -0
  169. package/src/infrastructure/sync/operationSyncIngest.js +82 -0
  170. package/src/infrastructure/sync/operationSyncSink.js +40 -0
  171. package/src/activeMelting.js +0 -54
  172. package/src/completedMelting.js +0 -42
  173. package/src/event.js +0 -24
  174. package/src/events.js +0 -51
  175. package/src/interval.js +0 -18
  176. package/src/machineChronology.js +0 -79
  177. package/src/meltingChronology.js +0 -37
  178. package/src/meltingMachine.js +0 -53
  179. package/src/meltingRuleEngine.js +0 -37
  180. package/src/meltingShop.js +0 -32
  181. package/src/meltings.js +0 -97
  182. package/src/monitoredMeltingMachine.js +0 -33
  183. package/src/plant.js +0 -23
  184. package/src/postgresSensor.js +0 -105
  185. package/src/requests.js +0 -44
  186. package/src/rule.js +0 -33
  187. package/src/rules.js +0 -23
  188. package/src/scyllaSensor.js +0 -54
  189. package/src/segments.js +0 -64
  190. package/src/sqliteSensor.js +0 -106
@@ -0,0 +1,204 @@
1
+ import {
2
+ batch,
3
+ circuit,
4
+ clock,
5
+ modbusRtuBusSource,
6
+ modbusRtuSource,
7
+ modbusSource,
8
+ mqttSink
9
+ } from '@yarkivaev/source-to-sink';
10
+ import { parseModbusDeviceSpec } from './modbusDeviceSpec.js';
11
+
12
+ /**
13
+ * Builds a TCP Modbus polling source for one device.
14
+ *
15
+ * @param {object} device - Parsed TCP device descriptor
16
+ * @param {object} config - Pipeline config
17
+ * @param {object} collector - Batch collector
18
+ * @param {object} clk - Clock instance
19
+ * @returns {object} Modbus polling source
20
+ */
21
+ function buildTcpSource(device, config, collector, clk) {
22
+ const transformer = config.transformerFactory(device.name, collector);
23
+ return modbusSource(
24
+ device.host,
25
+ device.port,
26
+ config.address,
27
+ config.count,
28
+ config.interval,
29
+ transformer,
30
+ clk
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Serial bus key for grouping RTU devices that share one port.
36
+ *
37
+ * @param {object} device - Parsed RTU device descriptor
38
+ * @returns {string} Group key
39
+ */
40
+ function rtuBusKey(device) {
41
+ const { baudRate, dataBits, stopBits, parity } = device.serial;
42
+ return `${device.path}|${baudRate}|${dataBits}|${stopBits}|${parity}`;
43
+ }
44
+
45
+ /**
46
+ * Groups RTU devices by shared serial port and line settings.
47
+ *
48
+ * @param {Array<object>} rtuDevices - Parsed RTU device descriptors
49
+ * @returns {Array<Array<object>>} Device groups
50
+ */
51
+ function groupRtuDevices(rtuDevices) {
52
+ const groups = new Map();
53
+ for (const device of rtuDevices) {
54
+ const key = rtuBusKey(device);
55
+ const list = groups.get(key) || [];
56
+ list.push(device);
57
+ groups.set(key, list);
58
+ }
59
+ return [...groups.values()];
60
+ }
61
+
62
+ /**
63
+ * Builds a single-slave RTU polling source.
64
+ *
65
+ * @param {object} device - Parsed RTU device
66
+ * @param {object} config - Pipeline config
67
+ * @param {object} collector - Batch collector
68
+ * @param {object} clk - Clock instance
69
+ * @returns {object} Modbus RTU source
70
+ */
71
+ function buildSingleRtuSource(device, config, collector, clk) {
72
+ return modbusRtuSource(
73
+ device.path,
74
+ device.serial,
75
+ config.address,
76
+ config.count,
77
+ config.interval,
78
+ config.transformerFactory(device.name, collector),
79
+ clk
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Builds a multi-slave RTU bus source for one serial port.
85
+ *
86
+ * @param {Array<object>} devices - RTU devices on the same port
87
+ * @param {object} config - Pipeline config
88
+ * @param {object} collector - Batch collector
89
+ * @returns {object} Modbus RTU bus source
90
+ */
91
+ function buildBusRtuSource(devices, config, collector) {
92
+ const slaves = devices.map((device) => {
93
+ return {
94
+ slaveId: device.serial.slaveId,
95
+ collector: config.transformerFactory(device.name, collector)
96
+ };
97
+ });
98
+ const first = devices[0];
99
+ return modbusRtuBusSource(first.path, first.serial, slaves, {
100
+ address: config.address,
101
+ count: config.count,
102
+ interval: config.interval
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Builds RTU sources, merging slaves on the same serial port into one bus.
108
+ *
109
+ * @param {Array<object>} rtuDevices - Parsed RTU device descriptors
110
+ * @param {object} config - Pipeline config
111
+ * @param {object} collector - Batch collector
112
+ * @param {object} clk - Clock instance
113
+ * @returns {Array<object>} Modbus polling sources
114
+ */
115
+ function buildRtuSources(rtuDevices, config, collector, clk) {
116
+ return groupRtuDevices(rtuDevices).map((devices) => {
117
+ if (devices.length === 1) {
118
+ return buildSingleRtuSource(devices[0], config, collector, clk);
119
+ }
120
+ return buildBusRtuSource(devices, config, collector);
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Validates modbusMqtt constructor arguments.
126
+ *
127
+ * @param {string} mqtt - MQTT broker URL
128
+ * @param {string} devices - Device specs string
129
+ * @param {object} config - Pipeline config
130
+ */
131
+ function assertModbusMqttArgs(mqtt, devices, config) {
132
+ if (typeof mqtt !== 'string' || mqtt.length === 0) {
133
+ throw new Error('MQTT URL must be a non-empty string');
134
+ }
135
+ if (typeof devices !== 'string' || devices.length === 0) {
136
+ throw new Error('Devices must be a non-empty string');
137
+ }
138
+ if (!config || typeof config !== 'object') {
139
+ throw new Error('Config must be an object');
140
+ }
141
+ if (typeof config.transformerFactory !== 'function') {
142
+ throw new Error('transformerFactory is required for modbusMqtt');
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Parses device specs and builds TCP plus RTU polling sources.
148
+ *
149
+ * @param {string} devices - Comma-separated device specs
150
+ * @param {object} config - Pipeline config
151
+ * @param {object} collector - Batch collector
152
+ * @param {object} clk - Clock instance
153
+ * @returns {Array<object>} Modbus polling sources
154
+ */
155
+ function buildSources(devices, config, collector, clk) {
156
+ const parsed = devices.split(',').map((spec) => {
157
+ return parseModbusDeviceSpec(spec.trim());
158
+ });
159
+ const tcp = parsed.filter((device) => {
160
+ return device.kind === 'tcp';
161
+ });
162
+ const rtu = parsed.filter((device) => {
163
+ return device.kind === 'rtu';
164
+ });
165
+ return [
166
+ ...tcp.map((device) => {
167
+ return buildTcpSource(device, config, collector, clk);
168
+ }),
169
+ ...buildRtuSources(rtu, config, collector, clk)
170
+ ];
171
+ }
172
+
173
+ /**
174
+ * Pipeline for polling Modbus devices and publishing to MQTT.
175
+ *
176
+ * @param {string} mqtt - MQTT broker URL
177
+ * @param {string} devices - Comma-separated specs: name:host:port or name:rtu:path:baud[:line][:slaveId]
178
+ * @param {object} config - interval, address, count, threshold, timeout, clientId, transformerFactory
179
+ * @returns {object} Pipeline with start() and stop() methods
180
+ */
181
+ export default function modbusMqtt(mqtt, devices, config) {
182
+ assertModbusMqttArgs(mqtt, devices, config);
183
+ const clk = clock();
184
+ const breaker = circuit(config.threshold, config.timeout, clk);
185
+ const sink = mqttSink(mqtt, {
186
+ clientId: config.clientId || 'scada-modbus',
187
+ qos: 1
188
+ });
189
+ const collector = batch(sink, 5, breaker);
190
+ const sources = buildSources(devices, config, collector, clk);
191
+ return {
192
+ start() {
193
+ sink.start();
194
+ for (const source of sources) {
195
+ source.start();
196
+ }
197
+ },
198
+ stop() {
199
+ for (const source of sources) {
200
+ source.stop();
201
+ }
202
+ }
203
+ };
204
+ }
@@ -0,0 +1,78 @@
1
+ import {
2
+ batch,
3
+ circuit,
4
+ clock,
5
+ mqttSource,
6
+ timedBatch
7
+ } from '@yarkivaev/source-to-sink';
8
+ import metricsCodec from './metricsTransformer.js';
9
+
10
+ /**
11
+ * Pipeline for streaming MQTT sensor data to a storage sink.
12
+ *
13
+ * Subscribes to MQTT topic and batches messages before writing
14
+ * to the provided sink. Uses circuit breaker for failure isolation
15
+ * and time-based flushing for low-volume periods.
16
+ *
17
+ * @example
18
+ * import { clickhouseSink } from '@yarkivaev/source-to-sink';
19
+ * const sink = clickhouseSink('http://localhost:8123', 'scada.metrics');
20
+ * const pipeline = mqttMetrics(
21
+ * 'mqtt://localhost:1883',
22
+ * sink,
23
+ * 'sensors/#',
24
+ * { size: 100, interval: 5, threshold: 5, timeout: 60 }
25
+ * );
26
+ * pipeline.start();
27
+ * // ... later
28
+ * pipeline.stop();
29
+ *
30
+ * @param {string} mqtt - MQTT broker URL
31
+ * @param {object} sink - Sink with write(records) method
32
+ * @param {string} topic - MQTT topic pattern to subscribe
33
+ * @param {object} config - Pipeline configuration
34
+ * @param {number} config.size - Batch size before flush
35
+ * @param {number} config.interval - Seconds before time-based flush
36
+ * @param {number} config.threshold - Circuit breaker failure threshold
37
+ * @param {number} config.timeout - Circuit breaker timeout in seconds
38
+ * @param {string} [config.clientId] - MQTT client ID for persistent sessions
39
+ * @param {number} [config.sessionExpiryInterval] - Session expiry in seconds (default 3600)
40
+ * @returns {object} Pipeline with start() and stop() methods
41
+ */
42
+ export default function mqttMetrics(mqtt, sink, topic, config) {
43
+ if (typeof mqtt !== 'string' || mqtt.length === 0) {
44
+ throw new Error('MQTT URL must be a non-empty string');
45
+ }
46
+ if (!sink || typeof sink.write !== 'function') {
47
+ throw new Error('Sink must have a write(records) method');
48
+ }
49
+ if (typeof topic !== 'string' || topic.length === 0) {
50
+ throw new Error('Topic must be a non-empty string');
51
+ }
52
+ if (!config || typeof config !== 'object') {
53
+ throw new Error('Config must be an object');
54
+ }
55
+ const clk = clock();
56
+ const breaker = circuit(config.threshold, config.timeout, clk);
57
+ const collector = timedBatch(batch(sink, config.size, breaker), config.interval);
58
+ const transformer = metricsCodec(collector);
59
+ const source = mqttSource(mqtt, topic, transformer, {
60
+ clientId: config.clientId,
61
+ sessionExpiryInterval: config.sessionExpiryInterval
62
+ });
63
+ return {
64
+ /**
65
+ * Starts the pipeline.
66
+ */
67
+ start() {
68
+ source.start();
69
+ },
70
+ /**
71
+ * Stops the pipeline.
72
+ */
73
+ stop() {
74
+ source.stop();
75
+ collector.stop();
76
+ }
77
+ };
78
+ }
@@ -0,0 +1,20 @@
1
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
2
+
3
+ /**
4
+ * Parses REQUEST_TIMEOUT_MS from environment into a positive millisecond value.
5
+ *
6
+ * @param {string|undefined} raw - env value
7
+ * @returns {number} timeout in milliseconds
8
+ */
9
+ export function parseRequestTimeoutMs(raw) {
10
+ if (raw === undefined || raw === null || raw === '') {
11
+ return DEFAULT_REQUEST_TIMEOUT_MS;
12
+ }
13
+ const ms = Number.parseInt(String(raw), 10);
14
+ if (!Number.isFinite(ms) || ms <= 0) {
15
+ return DEFAULT_REQUEST_TIMEOUT_MS;
16
+ }
17
+ return ms;
18
+ }
19
+
20
+ export { DEFAULT_REQUEST_TIMEOUT_MS };
@@ -0,0 +1,48 @@
1
+ import { stompSource } from '@yarkivaev/source-to-sink';
2
+ import alertCodec from '../codecs/alertCodec.js';
3
+ import alertSink from '../sinks/alertSink.js';
4
+
5
+ /**
6
+ * Pipeline for streaming STOMP alert data to PostgreSQL alerts table.
7
+ *
8
+ * Subscribes to STOMP exchange and forwards alert messages directly
9
+ * to the alert sink without batching. Alerts are rare and require
10
+ * per-record INSERT/UPDATE branching, making batching unnecessary.
11
+ *
12
+ * Plant packages pass human-readable messages via config.translations
13
+ * (rule name → message). Without translations, the rule name is stored.
14
+ *
15
+ * @example
16
+ * const pipeline = alertPipeline(
17
+ * 'stomp://rabbitmq:61613',
18
+ * pool,
19
+ * { login: 'guest', passcode: 'guest', host: '/', translations: { no_data: 'No data' } }
20
+ * );
21
+ * pipeline.start();
22
+ *
23
+ * @param {string} stomp - STOMP broker URL
24
+ * @param {object} pool - PostgreSQL pool
25
+ * @param {object} config - Pipeline configuration
26
+ * @returns {object} Pipeline with start() and stop() methods
27
+ */
28
+ export default function alertPipeline(stomp, pool, config) {
29
+ const sink = alertSink(pool);
30
+ const codec = alertCodec(sink, config.translations || {});
31
+ const source = stompSource(stomp, '/exchange/scada.alerts', codec,
32
+ { login: config.login, passcode: config.passcode, host: config.host });
33
+ return {
34
+ /**
35
+ * Starts the pipeline.
36
+ */
37
+ start() {
38
+ source.start();
39
+ },
40
+ /**
41
+ * Stops the pipeline.
42
+ */
43
+ stop() {
44
+ source.stop();
45
+ sink.stop();
46
+ }
47
+ };
48
+ }
@@ -0,0 +1,45 @@
1
+ import { stompSource } from '@yarkivaev/source-to-sink';
2
+ import userDecisionCodec from '../codecs/userDecisionCodec.js';
3
+ import userDecisionSink from '../sinks/userDecisionSink.js';
4
+
5
+ /**
6
+ * Pipeline for logging operator tag decisions from RabbitMQ to PostgreSQL.
7
+ *
8
+ * Subscribes to the user_decisions STOMP exchange and inserts each received
9
+ * decision into the user_decisions audit table. Decisions carry machine,
10
+ * start timestamp, operator identity, and full JSON payload.
11
+ *
12
+ * @example
13
+ * const pipeline = decisionPipeline(
14
+ * 'stomp://rabbitmq:61613',
15
+ * 'postgresql://scada:scada@postgres/scada',
16
+ * { login: 'guest', passcode: 'guest', host: '/' }
17
+ * );
18
+ * pipeline.start();
19
+ *
20
+ * @param {string} stomp - STOMP broker URL
21
+ * @param {string} postgres - PostgreSQL connection URL
22
+ * @param {object} config - Pipeline configuration
23
+ * @returns {object} Pipeline with start() and stop() methods
24
+ */
25
+ export default function decisionPipeline(stomp, pool, config) {
26
+ const sink = userDecisionSink(pool);
27
+ const codec = userDecisionCodec(sink);
28
+ const source = stompSource(stomp, '/exchange/scada.user_decisions', codec,
29
+ { login: config.login, passcode: config.passcode, host: config.host });
30
+ return {
31
+ /**
32
+ * Starts consuming user decisions from RabbitMQ.
33
+ */
34
+ start() {
35
+ source.start();
36
+ },
37
+ /**
38
+ * Stops consuming and releases resources.
39
+ */
40
+ stop() {
41
+ source.stop();
42
+ pool.end();
43
+ }
44
+ };
45
+ }
@@ -0,0 +1,60 @@
1
+ import {
2
+ postgresSink,
3
+ stompSource
4
+ } from '@yarkivaev/source-to-sink';
5
+ import segmentDispatch from '../../../domain/segment/dispatch.js';
6
+ import silenceBudget from '../../../domain/segment/silenceBudget.js';
7
+ import segmentCodec from '../codecs/segmentCodec.js';
8
+ import silentOpenWatch from '../silentOpenWatch.js';
9
+ import closeOrphanOpen from '../sinks/closeOrphanOpen.js';
10
+ import closeSilentOpen from '../sinks/closeSilentOpen.js';
11
+ import retagSink from '../sinks/retagSink.js';
12
+
13
+ export const segmentColumns = ['machine', 'name', 'start_time', 'end_time', 'duration',
14
+ 'options', 'tags', 'properties', 'resolved'];
15
+ export const segmentConflict = ['machine', 'start_time'];
16
+ export const segmentUpdateColumns = ['name', 'end_time', 'duration', 'options', 'resolved'];
17
+ export const splitUpdateColumns = ['name', 'end_time', 'duration', 'tags', 'options', 'resolved'];
18
+ export const segmentsIngestDestination = '/queue/scada.segments.ingest';
19
+
20
+ export { default as segmentDispatch } from '../../../domain/segment/dispatch.js';
21
+
22
+ /**
23
+ * Pipeline for streaming STOMP segment data to PostgreSQL segments table.
24
+ * Subscribes to the durable ingest queue so shovel traffic survives consumer gaps.
25
+ *
26
+ * @param {string} stomp - STOMP broker URL
27
+ * @param {string} postgres - PostgreSQL connection URL
28
+ * @param {object} pool - pg pool
29
+ * @param {object} config - Pipeline configuration
30
+ * @returns {object} Pipeline with start() and stop() methods
31
+ */
32
+ export default function segmentPipeline(stomp, postgres, pool, config) {
33
+ const segmentSink = postgresSink(postgres, 'segments', segmentColumns,
34
+ { conflict: segmentConflict, update: segmentUpdateColumns });
35
+ const splitSink = postgresSink(postgres, 'segments', segmentColumns,
36
+ { conflict: segmentConflict, update: splitUpdateColumns });
37
+ const retag = retagSink(pool);
38
+ const closer = closeOrphanOpen(pool);
39
+ const dispatch = segmentDispatch(segmentSink, retag, splitSink, closer);
40
+ const codec = segmentCodec(dispatch);
41
+ const destination = config.segmentsDestination || segmentsIngestDestination;
42
+ const source = stompSource(stomp, destination, codec,
43
+ { login: config.login, passcode: config.passcode, host: config.host });
44
+ const window = config.segmentWindow || 15;
45
+ const budget = silenceBudget(window);
46
+ const pollMs = (config.poll || 5) * 1000;
47
+ const silence = silentOpenWatch(closeSilentOpen(pool), budget, { intervalMs: pollMs });
48
+ return {
49
+ destination,
50
+ start() {
51
+ source.start();
52
+ void silence.start();
53
+ },
54
+ stop() {
55
+ silence.stop();
56
+ source.stop();
57
+ pool.end();
58
+ }
59
+ };
60
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Emits a structured processing error line to stderr.
3
+ *
4
+ * Keeps payloads compact to avoid flooding logs with huge messages.
5
+ *
6
+ * @param {string} stage - Processing stage identifier
7
+ * @param {Error} error - Original processing error
8
+ * @param {object} context - Optional diagnostic context
9
+ */
10
+ export default function processingErrorLog(stage, error, context = {}) {
11
+ const safe = {};
12
+ for (const [key, value] of Object.entries(context)) {
13
+ if (typeof value === 'string' && value.length > 500) {
14
+ safe[key] = `${value.slice(0, 500)}...[truncated]`;
15
+ } else {
16
+ safe[key] = value;
17
+ }
18
+ }
19
+ const payload = {
20
+ stage,
21
+ message: error?.message || String(error),
22
+ context: safe
23
+ };
24
+ process.stderr.write(`[supervisor-sink] processing error ${JSON.stringify(payload)}\n`);
25
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Periodic wall-clock sweep that closes stale open segments without waiting for STOMP.
3
+ *
4
+ * @param {object} closer - closeSilentOpen result with close(budgetSeconds)
5
+ * @param {number} budget - silence budget in seconds
6
+ * @param {object} options - intervalMs for sweep period
7
+ * @returns {object} watch with start() and stop()
8
+ *
9
+ * @example
10
+ * const watch = silentOpenWatch(closer, 30, { intervalMs: 5000 });
11
+ * await watch.start();
12
+ */
13
+ export default function silentOpenWatch(closer, budget, options) {
14
+ if (!closer || typeof closer.close !== 'function') {
15
+ throw new Error('Closer must have a close() method');
16
+ }
17
+ if (typeof budget !== 'number' || !(budget > 0)) {
18
+ throw new Error(`Budget must be a positive number: ${budget}`);
19
+ }
20
+ const intervalMs = options && options.intervalMs ? options.intervalMs : 5000;
21
+ let timer;
22
+ let active = false;
23
+ async function tick() {
24
+ try {
25
+ await closer.close(budget);
26
+ } catch {
27
+ /* next tick retries */
28
+ }
29
+ }
30
+ return {
31
+ async start() {
32
+ if (active) {
33
+ return;
34
+ }
35
+ active = true;
36
+ await tick();
37
+ timer = setInterval(() => {
38
+ void tick();
39
+ }, intervalMs);
40
+ },
41
+ stop() {
42
+ if (!active) {
43
+ return;
44
+ }
45
+ active = false;
46
+ clearInterval(timer);
47
+ timer = undefined;
48
+ }
49
+ };
50
+ }
@@ -0,0 +1,43 @@
1
+ import { alertIngestAction } from '../../../domain/alerting/ingest.js';
2
+ import processingErrorLog from '../processingErrorLog.js';
3
+
4
+ /**
5
+ * PostgreSQL sink for alert records with INSERT/UPDATE branching.
6
+ *
7
+ * @param {object} pool - pg.Pool instance with query() method
8
+ * @returns {object} Sink with accept() and stop() methods
9
+ *
10
+ * @example
11
+ * const sink = alertSink(pool);
12
+ * await sink.accept({ name: 'low_cosphi', message: 'msg', machine: 'm2', severity: 'warning', status: 'pending', timestamp: '2023-11-14T22:13:20.000Z' });
13
+ */
14
+ export default function alertSink(pool) {
15
+ return {
16
+ async accept(record) {
17
+ try {
18
+ const action = alertIngestAction(record);
19
+ if (action === 'insert') {
20
+ await pool.query(
21
+ 'INSERT INTO alerts (name, message, machine, severity, timestamp) VALUES ($1, $2, $3, $4, $5)',
22
+ [record.name, record.message, record.machine, record.severity, record.timestamp]
23
+ );
24
+ } else if (action === 'acknowledge') {
25
+ await pool.query(
26
+ 'UPDATE alerts SET acknowledged = TRUE WHERE name = $1 AND machine = $2 AND acknowledged = FALSE',
27
+ [record.name, record.machine]
28
+ );
29
+ }
30
+ } catch (error) {
31
+ processingErrorLog('alert_sink_write', error, {
32
+ name: record.name,
33
+ machine: record.machine,
34
+ status: record.status
35
+ });
36
+ throw error;
37
+ }
38
+ },
39
+ async stop() {
40
+ await pool.end();
41
+ }
42
+ };
43
+ }
@@ -0,0 +1,32 @@
1
+ import processingErrorLog from '../processingErrorLog.js';
2
+
3
+ /**
4
+ * Closes stale open segment rows for one machine before a new open segment lands.
5
+ *
6
+ * @param {object} pool - pg Pool with query() method
7
+ * @returns {object} closer with close(machine, startTime)
8
+ *
9
+ * @example
10
+ * const closer = closeOrphanOpen(pool);
11
+ * await closer.close('m1', '2024-01-01T00:00:00.000Z');
12
+ */
13
+ export default function closeOrphanOpen(pool) {
14
+ if (!pool || typeof pool.query !== 'function') {
15
+ throw new Error('Pool must have a query() method');
16
+ }
17
+ return {
18
+ async close(machine, startTime) {
19
+ try {
20
+ await pool.query(
21
+ `UPDATE segments
22
+ SET end_time = start_time + interval '1 second', duration = 1
23
+ WHERE machine = $1 AND duration = 0 AND start_time <> $2`,
24
+ [machine, startTime]
25
+ );
26
+ } catch (error) {
27
+ processingErrorLog('close_orphan_open', error, { machine, startTime });
28
+ throw error;
29
+ }
30
+ }
31
+ };
32
+ }
@@ -0,0 +1,39 @@
1
+ import processingErrorLog from '../processingErrorLog.js';
2
+
3
+ /**
4
+ * Closes open segment rows whose advanced end boundary is older than a wall-clock silence budget.
5
+ * Requires end_time > start_time so a lone Started (end equals start) is never treated as silence.
6
+ * Does not insert a replacement Started row.
7
+ *
8
+ * @param {object} pool - pg Pool with query() method
9
+ * @returns {object} closer with close(budgetSeconds)
10
+ *
11
+ * @example
12
+ * const closer = closeSilentOpen(pool);
13
+ * await closer.close(30);
14
+ */
15
+ export default function closeSilentOpen(pool) {
16
+ if (!pool || typeof pool.query !== 'function') {
17
+ throw new Error('Pool must have a query() method');
18
+ }
19
+ return {
20
+ async close(budgetSeconds) {
21
+ try {
22
+ await pool.query(
23
+ `UPDATE segments
24
+ SET duration = GREATEST(
25
+ 1,
26
+ EXTRACT(EPOCH FROM (end_time - start_time))
27
+ )
28
+ WHERE duration = 0
29
+ AND end_time > start_time
30
+ AND end_time < NOW() - make_interval(secs => $1)`,
31
+ [budgetSeconds]
32
+ );
33
+ } catch (error) {
34
+ processingErrorLog('close_silent_open', error, { budgetSeconds });
35
+ throw error;
36
+ }
37
+ }
38
+ };
39
+ }