@yarkivaev/scada 1.4.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 (189) 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 -45
  17. package/package.json +28 -8
  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/requests.js +0 -44
  185. package/src/rule.js +0 -33
  186. package/src/rules.js +0 -23
  187. package/src/scyllaSensor.js +0 -54
  188. package/src/segments.js +0 -64
  189. package/src/sqliteSensor.js +0 -106
@@ -0,0 +1,43 @@
1
+ import processingErrorLog from '../processingErrorLog.js';
2
+
3
+ /**
4
+ * Sink for applying operator-assigned tags to an existing segment row.
5
+ *
6
+ * Executes a targeted UPDATE that sets tags, properties, clears options,
7
+ * and marks the segment as resolved. Does not touch name, duration, or
8
+ * timestamps, preserving the segment's structural identity.
9
+ *
10
+ * @example
11
+ * const pool = new pg.Pool({ connectionString: url });
12
+ * const sink = retagSink(pool);
13
+ * sink.accept({ machine: 'm2', start_time: '2024-01-01T00:00:00.000Z',
14
+ * tags: '["charge_loading"]', properties: '{}' });
15
+ *
16
+ * @param {object} pool - pg Pool (or compatible) with query() method
17
+ * @returns {object} Sink with accept() method
18
+ */
19
+ export default function retagSink(pool) {
20
+ if (!pool || typeof pool.query !== 'function') {
21
+ throw new Error('Pool must have a query() method');
22
+ }
23
+ return {
24
+ /**
25
+ * Applies tags and properties to the segment identified by machine and start_time.
26
+ *
27
+ * @param {object} record - Record with machine, start_time, tags, properties
28
+ */
29
+ async accept({ machine, start_time: startTime, tags, properties, options, resolved }) {
30
+ try {
31
+ const opts = options === undefined || options === null ? null : options;
32
+ const flag = typeof resolved === 'boolean' ? resolved : true;
33
+ await pool.query(
34
+ 'UPDATE segments SET tags = $1, properties = $2, options = $3, resolved = $4 WHERE machine = $5 AND start_time = $6',
35
+ [tags, properties, opts, flag, machine, startTime]
36
+ );
37
+ } catch (error) {
38
+ processingErrorLog('retag_sink_update', error, { machine, startTime, tags, properties });
39
+ throw error;
40
+ }
41
+ }
42
+ };
43
+ }
@@ -0,0 +1,41 @@
1
+ import processingErrorLog from '../processingErrorLog.js';
2
+
3
+ /**
4
+ * Sink for inserting user tag decisions into the user_decisions audit table.
5
+ *
6
+ * Each accepted record is persisted with machine, start_time, username,
7
+ * operator_id, decided_at, and the full raw JSON payload.
8
+ *
9
+ * @example
10
+ * const sink = userDecisionSink(pool);
11
+ * sink.accept({ machine: 'm2', startTime: '2024-01-01T00:00:00.000Z',
12
+ * username: 'Ivan Petrov', operatorId: 2,
13
+ * decidedAt: '2024-01-01T00:01:00.000Z',
14
+ * payload: '{"tags":["charge_loading"]}' });
15
+ *
16
+ * @param {object} pool - pg Pool (or compatible) with query() method
17
+ * @returns {object} Sink with accept() method
18
+ */
19
+ export default function userDecisionSink(pool) {
20
+ if (!pool || typeof pool.query !== 'function') {
21
+ throw new Error('Pool must have a query() method');
22
+ }
23
+ return {
24
+ /**
25
+ * Inserts a user decision record into the user_decisions table.
26
+ *
27
+ * @param {object} record - machine, startTime, username, operatorId, decidedAt, payload
28
+ */
29
+ async accept({ machine, startTime, username, operatorId, decidedAt, payload }) {
30
+ try {
31
+ await pool.query(
32
+ 'INSERT INTO user_decisions (machine, start_time, username, operator_id, decided_at, payload) VALUES ($1, $2, $3, $4, $5, $6)',
33
+ [machine, startTime, username, operatorId ?? null, decidedAt, payload]
34
+ );
35
+ } catch (error) {
36
+ processingErrorLog('decision_sink_insert', error, { machine, startTime, username, operatorId, decidedAt, payload });
37
+ throw error;
38
+ }
39
+ }
40
+ };
41
+ }
@@ -0,0 +1,94 @@
1
+ import amqp from 'amqplib';
2
+ import { batch, circuit, clock, timedBatch } from '@yarkivaev/source-to-sink';
3
+ import deliverToMqttRecord from './deliverToMqttRecord.js';
4
+ import metricsCodec from '../mqtt/metricsTransformer.js';
5
+ import streamNameFromTopic from './streamNameFromTopic.js';
6
+
7
+ /**
8
+ * Maps AMQP deliver to raw metrics message for metricsCodec.
9
+ *
10
+ * @param {object} codec - metricsCodec instance
11
+ * @param {object} fields - AMQP deliver fields
12
+ * @param {Buffer} content - message body
13
+ * @param {Function} [onSeen] - optional callback(streamName) for edge freshness
14
+ */
15
+ export function acceptTelemetryDeliver(codec, fields, content, onSeen) {
16
+ const record = deliverToMqttRecord(fields, content);
17
+ if (typeof onSeen === 'function') {
18
+ const name = streamNameFromTopic(record.topic);
19
+ if (name) {
20
+ onSeen(name);
21
+ }
22
+ }
23
+ codec.accept({ topic: record.topic, payload: record.payload.toString() });
24
+ }
25
+
26
+ /**
27
+ * Builds a queue consumer callback bound to one AMQP channel.
28
+ *
29
+ * @param {object} codec - metricsCodec instance
30
+ * @param {object} channel - amqplib channel with ack(msg)
31
+ * @param {Function} [onSeen] - optional edge freshness callback
32
+ * @returns {Function} consume callback for ch.consume
33
+ */
34
+ export function telemetryConsumer(codec, channel, onSeen) {
35
+ return (msg) => {
36
+ if (!msg) {
37
+ return;
38
+ }
39
+ acceptTelemetryDeliver(codec, msg.fields, msg.content, onSeen);
40
+ channel.ack(msg);
41
+ };
42
+ }
43
+
44
+ /**
45
+ * AMQP queue consumer that writes federated telemetry to a metrics sink.
46
+ *
47
+ * @param {string} amqpUrl - RabbitMQ AMQP URL
48
+ * @param {string} queue - Durable queue name to consume
49
+ * @param {object} sink - sink with write(records)
50
+ * @param {object} [options] - batch and prefetch options
51
+ * @returns {object} ingest with start and stop
52
+ */
53
+ export default function amqpMetricsIngest(amqpUrl, queue, sink, options = {}) {
54
+ const prefetch = options.prefetch || 32;
55
+ const size = options.size || 100;
56
+ const interval = options.interval || 5;
57
+ const threshold = options.threshold || 5;
58
+ const timeout = options.timeout || 60;
59
+ let session;
60
+ const clk = clock();
61
+ const breaker = circuit(threshold, timeout, clk);
62
+ const collector = timedBatch(batch(sink, size, breaker), interval);
63
+ const codec = metricsCodec(collector);
64
+ return {
65
+ async start() {
66
+ if (session) {
67
+ return;
68
+ }
69
+ const conn = await amqp.connect(amqpUrl);
70
+ const ch = await conn.createChannel();
71
+ if (session) {
72
+ await ch.close();
73
+ await conn.close();
74
+ return;
75
+ }
76
+ await ch.assertQueue(queue, { durable: true });
77
+ ch.prefetch(prefetch);
78
+ const onMessage = telemetryConsumer(codec, ch, options.onSeen);
79
+ const tag = await ch.consume(queue, onMessage, { noAck: false });
80
+ session = { conn, channel: ch, tag: tag.consumerTag };
81
+ },
82
+ async stop() {
83
+ const active = session;
84
+ if (!active) {
85
+ return;
86
+ }
87
+ session = undefined;
88
+ await active.channel.cancel(active.tag);
89
+ await active.channel.close();
90
+ await active.conn.close();
91
+ collector.stop();
92
+ }
93
+ };
94
+ }
@@ -0,0 +1,71 @@
1
+ import amqp from 'amqplib';
2
+ import deliverToMqttRecord from './deliverToMqttRecord.js';
3
+
4
+ /**
5
+ * Builds a queue consumer callback bound to one AMQP channel.
6
+ *
7
+ * @param {object} sink - sink with write(records)
8
+ * @param {object} channel - amqplib channel with ack(msg)
9
+ * @returns {Function} consume callback for ch.consume
10
+ */
11
+ export function relayConsumer(sink, channel) {
12
+ return (msg) => {
13
+ if (!msg) {
14
+ return;
15
+ }
16
+ const record = deliverToMqttRecord(msg.fields, msg.content);
17
+ sink.write([record]);
18
+ channel.ack(msg);
19
+ };
20
+ }
21
+
22
+ /**
23
+ * AMQP queue consumer that republishes messages to MQTT using routing key as topic.
24
+ *
25
+ * @example
26
+ * import { mqttSink } from '@yarkivaev/source-to-sink';
27
+ * const sink = mqttSink('mqtt://localhost:1883', { qos: 1 });
28
+ * const relay = amqpMqttRelay('amqp://localhost', 'scada.telemetry.ingest', sink);
29
+ * relay.start();
30
+ *
31
+ * @param {string} amqpUrl - RabbitMQ AMQP URL
32
+ * @param {string} queue - Durable queue name to consume
33
+ * @param {object} sink - mqttSink with start, stop, write
34
+ * @param {object} [options] - prefetch count
35
+ * @returns {object} Relay with start and stop
36
+ */
37
+ export default function amqpMqttRelay(amqpUrl, queue, sink, options = {}) {
38
+ const prefetch = options.prefetch || 32;
39
+ let session;
40
+ return {
41
+ async start() {
42
+ if (session) {
43
+ return;
44
+ }
45
+ const conn = await amqp.connect(amqpUrl);
46
+ const ch = await conn.createChannel();
47
+ if (session) {
48
+ await ch.close();
49
+ await conn.close();
50
+ return;
51
+ }
52
+ await ch.assertQueue(queue, { durable: true });
53
+ ch.prefetch(prefetch);
54
+ sink.start();
55
+ const onMessage = relayConsumer(sink, ch);
56
+ const tag = await ch.consume(queue, onMessage, { noAck: false });
57
+ session = { conn, channel: ch, tag: tag.consumerTag };
58
+ },
59
+ async stop() {
60
+ const active = session;
61
+ if (!active) {
62
+ return;
63
+ }
64
+ session = undefined;
65
+ await active.channel.cancel(active.tag);
66
+ await active.channel.close();
67
+ await active.conn.close();
68
+ sink.stop();
69
+ }
70
+ };
71
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Maps AMQP deliver fields to MQTT publish record.
3
+ *
4
+ * @example
5
+ * const record = deliverToMqttRecord({ routingKey: 'MX210.m-1.GET.AI1.VALUE' }, Buffer.from('1'));
6
+ * // record.topic === 'MX210/m-1/GET/AI1/VALUE'
7
+ *
8
+ * @param {object} fields - AMQP deliver fields with routingKey
9
+ * @param {Buffer|string} body - Raw message body
10
+ * @returns {object} Record with topic and payload for mqttSink
11
+ */
12
+ export default function deliverToMqttRecord(fields, body) {
13
+ const { routingKey } = fields;
14
+ if (typeof routingKey !== 'string' || routingKey.length === 0) {
15
+ throw new Error('routing key is empty for telemetry deliver');
16
+ }
17
+ const topic = routingKey.replace(/\./gu, '/');
18
+ return { topic, payload: body };
19
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Extracts the stream id from a metrics MQTT/AMQP topic path.
3
+ *
4
+ * @example
5
+ * streamNameFromTopic('MX210/m-1/GET/AI1/VALUE') === 'm-1'
6
+ *
7
+ * @param {string} topic - Slash-separated topic
8
+ * @returns {string|undefined} Device/stream id
9
+ */
10
+ export default function streamNameFromTopic(topic) {
11
+ if (typeof topic !== 'string' || topic.length === 0) {
12
+ return undefined;
13
+ }
14
+ const parts = topic.split('/').filter((part) => {
15
+ return part.length > 0;
16
+ });
17
+ if (parts.length < 2) {
18
+ return undefined;
19
+ }
20
+ return parts[1];
21
+ }
@@ -0,0 +1,96 @@
1
+ function trimBase(url) {
2
+ return String(url).replace(/\/$/u, '');
3
+ }
4
+
5
+ function authHeaders(token) {
6
+ const headers = { 'Content-Type': 'application/json' };
7
+ if (token) {
8
+ headers.Authorization = `Bearer ${token}`;
9
+ }
10
+ return headers;
11
+ }
12
+
13
+ function ownerError(message, code, status, cause) {
14
+ const err = new Error(message);
15
+ err.routeCode = code;
16
+ err.routeStatus = status;
17
+ if (cause) {
18
+ err.cause = cause;
19
+ }
20
+ return err;
21
+ }
22
+
23
+ async function readError(res) {
24
+ const text = await res.text();
25
+ return text.length > 0 ? text : res.statusText;
26
+ }
27
+
28
+ async function readJson(res) {
29
+ const text = await res.text();
30
+ if (!text || text.trim().length === 0) {
31
+ return undefined;
32
+ }
33
+ return JSON.parse(text);
34
+ }
35
+
36
+ /**
37
+ * HTTP operations write port that proxies create/update/delete to an edge plant API.
38
+ *
39
+ * Matches the owning-edge contract: central never upserts locally for edge machines.
40
+ * Failures surface as route errors (503 unreachable, 502 non-ok).
41
+ *
42
+ * @param {object} site - edge owner with baseUrl and optional token/fetch
43
+ * @param {string} machineId - machine identifier
44
+ * @returns {object} operations write port
45
+ *
46
+ * @example
47
+ * const port = httpOperations({ baseUrl: 'http://edge/api/v1' }, 'm2');
48
+ * await port.create({ kind: 'bath', payload: {}, operatorId: 2 });
49
+ */
50
+ export default function httpOperations(site, machineId) {
51
+ const base = trimBase(site.baseUrl);
52
+ const fetcher = site.fetch || fetch;
53
+ async function send(method, path, body) {
54
+ const url = `${base}${path}`;
55
+ let res;
56
+ try {
57
+ res = await fetcher(url, {
58
+ method,
59
+ headers: authHeaders(site.token),
60
+ body: body === undefined ? undefined : JSON.stringify(body)
61
+ });
62
+ } catch (cause) {
63
+ throw ownerError(
64
+ `owner operations unreachable for ${machineId}: ${cause.message}`,
65
+ 'SERVICE_UNAVAILABLE',
66
+ 503,
67
+ cause
68
+ );
69
+ }
70
+ if (!res.ok) {
71
+ throw ownerError(
72
+ `owner operations ${method} ${path} for ${machineId} failed: ${res.status} ${await readError(res)}`,
73
+ 'BAD_GATEWAY',
74
+ 502
75
+ );
76
+ }
77
+ return readJson(res);
78
+ }
79
+ const root = `/machines/${encodeURIComponent(machineId)}/operations`;
80
+ return Object.freeze({
81
+ create(body) {
82
+ return send('POST', root, body);
83
+ },
84
+ update(key, body) {
85
+ return send('PUT', `${root}/${encodeURIComponent(key)}`, body);
86
+ },
87
+ remove(key, body) {
88
+ return send('DELETE', `${root}/${encodeURIComponent(key)}`, body);
89
+ },
90
+ decisions(key) {
91
+ return send('GET', `${root}/${encodeURIComponent(key)}/decisions`).then((body) => {
92
+ return Array.isArray(body && body.items) ? body.items : [];
93
+ });
94
+ }
95
+ });
96
+ }
@@ -0,0 +1,99 @@
1
+ function trimBase(url) {
2
+ return String(url).replace(/\/$/u, '');
3
+ }
4
+
5
+ function authHeaders(token) {
6
+ const headers = { 'Content-Type': 'application/json' };
7
+ if (token) {
8
+ headers.Authorization = `Bearer ${token}`;
9
+ }
10
+ return headers;
11
+ }
12
+
13
+ function writeBody(start, tags, properties, audit) {
14
+ const body = {
15
+ start: start.toISOString(),
16
+ tags: tags || [],
17
+ properties: properties || {}
18
+ };
19
+ if (audit && audit.id !== undefined && audit.id !== null) {
20
+ body.operatorId = audit.id;
21
+ }
22
+ return body;
23
+ }
24
+
25
+ function ownerError(message, code, status, cause) {
26
+ const err = new Error(message);
27
+ err.routeCode = code;
28
+ err.routeStatus = status;
29
+ if (cause) {
30
+ err.cause = cause;
31
+ }
32
+ return err;
33
+ }
34
+
35
+ async function readError(res) {
36
+ const text = await res.text();
37
+ return text.length > 0 ? text : res.statusText;
38
+ }
39
+
40
+ /**
41
+ * HTTP timeline write port that proxies retag/respond to an edge plant API.
42
+ *
43
+ * Matches stompTimeline shape: retag(start, tags, properties, audit) and
44
+ * respond(start, tags, properties, audit). Failures surface as route errors.
45
+ *
46
+ * @param {object} site - edge owner with baseUrl and optional token/fetch
47
+ * @param {string} machineId - machine identifier
48
+ * @returns {object} timeline write port
49
+ *
50
+ * @example
51
+ * const port = httpTimeline({ baseUrl: 'http://edge/api/v1' }, 'm2');
52
+ * await port.retag(start, ['heat'], {}, audit);
53
+ */
54
+ export default function httpTimeline(site, machineId) {
55
+ const base = trimBase(site.baseUrl);
56
+ const fetcher = site.fetch || fetch;
57
+ async function send(method, path, body) {
58
+ const url = `${base}${path}`;
59
+ let res;
60
+ try {
61
+ res = await fetcher(url, {
62
+ method,
63
+ headers: authHeaders(site.token),
64
+ body: JSON.stringify(body)
65
+ });
66
+ } catch (cause) {
67
+ throw ownerError(
68
+ `owner timeline unreachable for ${machineId}: ${cause.message}`,
69
+ 'SERVICE_UNAVAILABLE',
70
+ 503,
71
+ cause
72
+ );
73
+ }
74
+ if (!res.ok) {
75
+ throw ownerError(
76
+ `owner timeline ${method} ${path} for ${machineId} failed: ${res.status} ${await readError(res)}`,
77
+ 'BAD_GATEWAY',
78
+ 502
79
+ );
80
+ }
81
+ return res;
82
+ }
83
+ return Object.freeze({
84
+ async retag(start, tags, properties, audit) {
85
+ await send('PATCH', `/machines/${encodeURIComponent(machineId)}/segments`, writeBody(start, tags, properties, audit));
86
+ },
87
+ async respond(start, tags, properties, audit) {
88
+ const requestId = encodeURIComponent(start.toISOString());
89
+ const body = {
90
+ tags: tags || [],
91
+ properties: properties || {}
92
+ };
93
+ if (audit && audit.id !== undefined && audit.id !== null) {
94
+ body.operatorId = audit.id;
95
+ }
96
+ await send('POST', `/machines/${encodeURIComponent(machineId)}/requests/${requestId}/respond`, body);
97
+ }
98
+ });
99
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Builds a machineId → owner registry from EDGE_SITES-style site lists.
3
+ *
4
+ * Unknown machines resolve to local (STOMP) ownership.
5
+ *
6
+ * @param {Array<{baseUrl: string, token?: string, machines: string[]}>} sites - edge sites
7
+ * @returns {object} frozen registry with resolve(machineId)
8
+ *
9
+ * @example
10
+ * const owners = machineOwners([
11
+ * { baseUrl: 'http://edge:3000/api/v1', token: 'secret', machines: ['m2'] }
12
+ * ]);
13
+ * owners.resolve('m2'); // { kind: 'edge', baseUrl, token }
14
+ * owners.resolve('m1'); // { kind: 'local' }
15
+ */
16
+ export default function machineOwners(sites) {
17
+ const map = new Map();
18
+ for (const site of sites || []) {
19
+ const owner = Object.freeze({
20
+ kind: 'edge',
21
+ baseUrl: site.baseUrl,
22
+ token: site.token
23
+ });
24
+ for (const id of site.machines || []) {
25
+ map.set(id, owner);
26
+ }
27
+ }
28
+ return Object.freeze({
29
+ /**
30
+ * Resolves ownership for one machine.
31
+ *
32
+ * @param {string} machineId - machine identifier
33
+ * @returns {object} { kind: 'local' } or { kind: 'edge', baseUrl, token }
34
+ */
35
+ resolve(machineId) {
36
+ return map.get(machineId) || Object.freeze({ kind: 'local' });
37
+ }
38
+ });
39
+ }
@@ -0,0 +1,28 @@
1
+ import httpTimeline from './httpTimeline.js';
2
+
3
+ /**
4
+ * Owner-routed timeline write factory.
5
+ *
6
+ * Local machines use the injected local write port (typically stompTimeline);
7
+ * edge-owned machines proxy PATCH/respond to the owning plant API.
8
+ *
9
+ * @param {function(string): object} localTimeline - factory (machineId) => write port
10
+ * @param {object} owners - registry with resolve(machineId) → local | edge owner
11
+ * @returns {function(string): object} factory (machineId) => write port
12
+ *
13
+ * @example
14
+ * const write = ownerTimeline(
15
+ * (id) => stompTimeline(decisions, id),
16
+ * machineOwners([{ baseUrl: 'http://edge/api/v1', machines: ['m2'] }])
17
+ * );
18
+ * await write('m2').retag(start, ['heat'], {}, audit);
19
+ */
20
+ export default function ownerTimeline(localTimeline, owners) {
21
+ return function forMachine(machineId) {
22
+ const owner = owners.resolve(machineId);
23
+ if (owner.kind === 'edge') {
24
+ return httpTimeline(owner, machineId);
25
+ }
26
+ return localTimeline(machineId);
27
+ };
28
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Read-only alert collection backed by PG hydration and STOMP.
3
+ *
4
+ * Loads unacknowledged alerts from PostgreSQL on init, subscribes to
5
+ * STOMP for real-time in-memory updates and SSE delivery.
6
+ *
7
+ * @param {object} hydrate - pgAlerts or compatible port
8
+ * @param {function} source - factory returning STOMP source with start/stop
9
+ * @param {object} translations - map of rule names to human-readable messages
10
+ * @returns {object} alert collection with init, all, find, stream, trigger, stop methods
11
+ *
12
+ * @example
13
+ * const history = stompAlerts(pgAlerts(pool), sourceFactory, { low_cosphi: 'Switch off...' });
14
+ * await history.init();
15
+ * history.all();
16
+ */
17
+ import stompAlertsCollection from './stompAlertsCollection.js';
18
+
19
+ export default function stompAlerts(hydrate, source, translations) {
20
+ return stompAlertsCollection(hydrate, source, translations);
21
+ }
@@ -0,0 +1,51 @@
1
+ import pubsub from '../../../../domain/shared/pubsub.js';
2
+ import stompAlertsInit from './stompAlertsInit.js';
3
+ import { build, consume } from './stompAlertsLogic.js';
4
+
5
+ /**
6
+ * @param {object} hydrate - alert hydration port
7
+ * @param {function} source - STOMP source factory
8
+ * @param {object} translations - rule name map
9
+ * @returns {object} alerts collection
10
+ */
11
+ export default function stompAlertsCollection(hydrate, source, translations) {
12
+ const items = [];
13
+ const bus = pubsub();
14
+ const state = { items, bus, counter: 0 };
15
+ let subscription = null;
16
+ return {
17
+ async init() {
18
+ await stompAlertsInit(hydrate, items, state);
19
+ subscription = source({
20
+ accept(raw) {
21
+ consume(raw, state, translations);
22
+ }
23
+ });
24
+ },
25
+ trigger(message, timestamp, object, name) {
26
+ state.counter += 1;
27
+ const alert = build({ id: state.counter, message, timestamp, machine: object, acknowledged: false, name });
28
+ items.push(alert);
29
+ bus.emit({ type: 'created', alert });
30
+ return alert;
31
+ },
32
+ all(...filters) {
33
+ return items.filter((a) => {
34
+ return filters.every((filter) => {
35
+ return filter(a);
36
+ });
37
+ });
38
+ },
39
+ find(id) {
40
+ return items.find((a) => {
41
+ return a.id === id;
42
+ });
43
+ },
44
+ stream: bus.stream,
45
+ stop() {
46
+ if (subscription) {
47
+ subscription.stop();
48
+ }
49
+ }
50
+ };
51
+ }
@@ -0,0 +1,24 @@
1
+ import { build } from './stompAlertsLogic.js';
2
+
3
+ /**
4
+ * Loads initial alerts from a hydration port into memory.
5
+ *
6
+ * @param {object} hydrate - port with listUnacknowledged
7
+ * @param {Array} items - mutable items array
8
+ * @param {object} state - shared state with counter
9
+ * @returns {Promise<void>}
10
+ */
11
+ export default async function stompAlertsInit(hydrate, items, state) {
12
+ const rows = await hydrate.listUnacknowledged({});
13
+ rows.forEach((row) => {
14
+ items.push(build({
15
+ id: row.id,
16
+ message: row.message,
17
+ timestamp: row.timestamp,
18
+ machine: row.machine,
19
+ acknowledged: row.acknowledged,
20
+ name: row.name
21
+ }));
22
+ state.counter = Math.max(state.counter, row.id);
23
+ });
24
+ }