@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,118 @@
1
+ /**
2
+ * Pulls one topic from Query for the given scope.
3
+ *
4
+ * @param {object} query - exportQuery port
5
+ * @param {object} scope - { kind, machine, from, to, step, keys, ... }
6
+ * @returns {Promise<*>} topic payload
7
+ */
8
+ function pull(query, scope) {
9
+ const { kind } = scope;
10
+ if (kind === 'machines') {
11
+ return query.machines();
12
+ }
13
+ if (kind === 'measurements') {
14
+ return query.measurements(scope.machine, scope);
15
+ }
16
+ if (kind === 'segments') {
17
+ return query.segments(scope.machine, scope);
18
+ }
19
+ if (kind === 'alerts') {
20
+ return query.alerts(scope.machine, scope);
21
+ }
22
+ if (kind === 'operations') {
23
+ return query.operations(scope.machine, scope);
24
+ }
25
+ throw new Error(`export job cannot pull unknown kind ${kind}`);
26
+ }
27
+
28
+ /**
29
+ * Opens one Stream subscription for the given scope.
30
+ *
31
+ * @param {object} stream - exportStream port
32
+ * @param {object} scope - { kind, machine, ... }
33
+ * @returns {object} connection with on/close
34
+ */
35
+ function open(stream, scope) {
36
+ const { kind } = scope;
37
+ if (kind === 'measurements') {
38
+ return stream.measurements(scope.machine, scope);
39
+ }
40
+ if (kind === 'segments') {
41
+ return stream.segments(scope.machine);
42
+ }
43
+ if (kind === 'alerts') {
44
+ return stream.alerts(scope.machine);
45
+ }
46
+ if (kind === 'operations') {
47
+ return stream.operations(scope.machine);
48
+ }
49
+ throw new Error(`export job cannot pipe unknown kind ${kind}`);
50
+ }
51
+
52
+ /**
53
+ * Default SSE event names for a stream kind.
54
+ *
55
+ * @param {string} kind - stream topic
56
+ * @returns {string[]} event names
57
+ */
58
+ function defaultEvents(kind) {
59
+ if (kind === 'measurements') {
60
+ return ['measurement'];
61
+ }
62
+ if (kind === 'segments') {
63
+ return ['segment_created', 'segment_resolved'];
64
+ }
65
+ if (kind === 'alerts') {
66
+ return ['alert'];
67
+ }
68
+ if (kind === 'operations') {
69
+ return ['operation_created', 'operation_updated'];
70
+ }
71
+ return [];
72
+ }
73
+
74
+ /**
75
+ * Export Job: pulls Query or Stream, transforms, then writes to Sink.
76
+ *
77
+ * Query path uses run(scope). Stream path uses pipe(scope) and returns stop().
78
+ * Transform and destination wiring belong to the caller (plant packages).
79
+ *
80
+ * @param {object} ports - { query?, stream?, transform?, sink }
81
+ * @returns {object} frozen job with run and pipe
82
+ *
83
+ * @example
84
+ * const job = exportJob({ query, transform: (rows) => rows, sink });
85
+ * await job.run({ kind: 'segments', machine: 'furnace-α', from, to });
86
+ *
87
+ * const live = exportJob({ stream, transform: (rows) => rows, sink });
88
+ * const handle = live.pipe({ kind: 'segments', machine: 'furnace-α', events: ['segment_created'] });
89
+ * handle.stop();
90
+ */
91
+ export default function exportJob(ports) {
92
+ const transform = ports.transform || function identity(rows) {
93
+ return rows;
94
+ };
95
+ return Object.freeze({
96
+ async run(scope) {
97
+ const rows = await pull(ports.query, scope);
98
+ const batch = await Promise.resolve(transform(rows));
99
+ return ports.sink.write(batch);
100
+ },
101
+ pipe(scope) {
102
+ const conn = open(ports.stream, scope);
103
+ const events = scope.events || defaultEvents(scope.kind);
104
+ events.forEach((name) => {
105
+ conn.on(name, (payload) => {
106
+ Promise.resolve(transform([payload])).then((batch) => {
107
+ return ports.sink.write(batch);
108
+ });
109
+ });
110
+ });
111
+ return Object.freeze({
112
+ stop() {
113
+ conn.close();
114
+ }
115
+ });
116
+ }
117
+ });
118
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * On-demand export Query port over plantApi via scadaClient.
3
+ *
4
+ * Reads machines, measurements, segments, alerts, and operations by range.
5
+ * Does not talk to storage directly and carries no plant-specific names.
6
+ *
7
+ * @param {object} client - scadaClient (or fake) with machines() and machine(id)
8
+ * @returns {object} frozen query with machines, measurements, segments, alerts, operations
9
+ *
10
+ * @example
11
+ * const query = exportQuery(scadaClient(url, fetch, EventSource));
12
+ * const rows = await query.segments('furnace-α', { from, to });
13
+ */
14
+ export default function exportQuery(client) {
15
+ return Object.freeze({
16
+ machines() {
17
+ return client.machines();
18
+ },
19
+ measurements(machineId, range) {
20
+ return client.machine(machineId).measurements(range);
21
+ },
22
+ segments(machineId, range) {
23
+ return client.machine(machineId).segments(range);
24
+ },
25
+ alerts(machineId, options) {
26
+ return client.machine(machineId).alerts(options || {});
27
+ },
28
+ operations(machineId, options) {
29
+ return client.machine(machineId).operations(options || {});
30
+ }
31
+ });
32
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Generic export Sink port for outbound batches and artifacts.
3
+ *
4
+ * Destination adapters (HTTP, file, plant integrations) live outside this package.
5
+ * This factory only freezes the write/send contract.
6
+ *
7
+ * @param {object} destination - object with write(records) and/or send(artifact)
8
+ * @returns {object} frozen sink with write and send
9
+ *
10
+ * @example
11
+ * const sink = exportSink({
12
+ * write(records) { return http.post('/hook', records); },
13
+ * send(artifact) { return http.post('/doc', artifact); }
14
+ * });
15
+ */
16
+ export default function exportSink(destination) {
17
+ return Object.freeze({
18
+ write(records) {
19
+ return destination.write(records);
20
+ },
21
+ send(artifact) {
22
+ return destination.send(artifact);
23
+ }
24
+ });
25
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Streaming export Stream port over plantApi SSE via scadaClient.
3
+ *
4
+ * Opens measurement, segment, alert, and operation subscriptions.
5
+ * Returns the same connection shape as scada/client (on/close).
6
+ *
7
+ * @param {object} client - scadaClient (or fake) with machine(id) stream methods
8
+ * @returns {object} frozen stream with measurements, segments, alerts, operations
9
+ *
10
+ * @example
11
+ * const stream = exportStream(client);
12
+ * const conn = stream.segments('furnace-α');
13
+ * conn.on('segment_created', (row) => { sink.write([row]); });
14
+ */
15
+ export default function exportStream(client) {
16
+ return Object.freeze({
17
+ measurements(machineId, options) {
18
+ return client.machine(machineId).measurementStream(options || {});
19
+ },
20
+ segments(machineId) {
21
+ return client.machine(machineId).segmentStream();
22
+ },
23
+ alerts(machineId) {
24
+ return client.machine(machineId).alertStream();
25
+ },
26
+ operations(machineId, notify) {
27
+ const listen = notify || ((payload) => {
28
+ return payload;
29
+ });
30
+ return client.machine(machineId).operationsStream(listen);
31
+ }
32
+ });
33
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Locates a machine inside a plant by id.
3
+ *
4
+ * @param {object} plant - plant with shops
5
+ * @param {string} id - machine id
6
+ * @returns {object|undefined} machine and shop or undefined
7
+ *
8
+ * @example
9
+ * const found = machineInPlant(plant, 'm1');
10
+ * found.machine.sensors;
11
+ */
12
+ export default function machineInPlant(plant, id) {
13
+ for (const area of Object.values(plant.shops.get())) {
14
+ const found = area.machines.get()[id];
15
+ if (found) {
16
+ return { machine: found, shop: area };
17
+ }
18
+ }
19
+ return undefined;
20
+ }
@@ -0,0 +1,35 @@
1
+ import plant from '../domain/plant/plant.js';
2
+ import initialized from '../domain/shared/initialized.js';
3
+ import postgresPool from '../infrastructure/persistence/postgresPool.js';
4
+
5
+ /**
6
+ * Generic plant factory wiring sensors and a shop factory over a connection.
7
+ *
8
+ * @param {Array<{device: string, machine: string}>} machines - machine descriptors
9
+ * @param {object} options - connection, buildSensors, shopFactory, pool, alerts, userDecisions, shopName
10
+ * @returns {Promise<object>} plant with shops and init
11
+ *
12
+ * @example
13
+ * const p = await metricsPlant(machines, {
14
+ * connection: () => clickhouseConnection(host, opts),
15
+ * buildSensors: (conn, entry) => ({ voltage: sensor(conn, entry) }),
16
+ * shopFactory: genericShop
17
+ * });
18
+ */
19
+ export default async function metricsPlant(machines, options) {
20
+ const connection = await options.connection();
21
+ const pool = postgresPool(options);
22
+ const entries = machines.map((entry) => {
23
+ return {
24
+ name: entry.machine,
25
+ sensors: options.buildSensors(connection, entry)
26
+ };
27
+ });
28
+ const shop = options.shopFactory(entries, {
29
+ pool,
30
+ alerts: options.alerts,
31
+ userDecisions: options.userDecisions,
32
+ shopName: options.shopName
33
+ });
34
+ return plant(initialized({ [shop.name()]: shop }, Object.values));
35
+ }
@@ -0,0 +1,49 @@
1
+ import machineRoute from '../infrastructure/http/plant/routes/machineRoute.js';
2
+ import measurementRoute from '../infrastructure/http/plant/routes/measurementRoute.js';
3
+ import measurementStream from '../infrastructure/http/plant/streams/measurementStream.js';
4
+ import alertRoute from '../infrastructure/http/plant/routes/alertRoute.js';
5
+ import alertStream from '../infrastructure/http/plant/streams/alertStream.js';
6
+ import timelineRoute from '../infrastructure/http/plant/routes/timelineRoute.js';
7
+ import timelineStream from '../infrastructure/http/plant/streams/timelineStream.js';
8
+ import operationRoute from '../infrastructure/http/plant/routes/operationRoute.js';
9
+ import operationStream from '../infrastructure/http/plant/streams/operationStream.js';
10
+ import heartbeatStream from '../infrastructure/http/plant/streams/heartbeatStream.js';
11
+ import simulationRoute from '../infrastructure/http/plant/routes/simulationRoute.js';
12
+ import catalogRoute from '../infrastructure/http/plant/routes/catalogRoute.js';
13
+ import { routes } from '@yarkivaev/simple-server';
14
+
15
+ /**
16
+ * Composable plant HTTP API factory.
17
+ *
18
+ * @param {string} basePath - base URL path
19
+ * @param {object} plant - plant domain object with operations (optional kindSources at construction)
20
+ * @param {object} [config] - clock, extraRoutes, requestTimeoutMs, heartbeat
21
+ * @returns {object} routes with list() and handle()
22
+ *
23
+ * @example
24
+ * const api = plantApi('/api/v1', plant, { extraRoutes: siteRoute('/api/v1', plant) });
25
+ */
26
+ export default function plantApi(basePath, plant, config) {
27
+ const opts = config || {};
28
+ plant.init();
29
+ const time = opts.clock || (() => {
30
+ return new Date();
31
+ });
32
+ const extra = opts.extraRoutes || [];
33
+ const routeList = [
34
+ ...catalogRoute(basePath, opts.tagCatalog),
35
+ ...machineRoute(basePath, plant),
36
+ ...measurementStream(basePath, plant, time),
37
+ ...measurementRoute(basePath, plant, time),
38
+ ...alertStream(basePath, plant, time),
39
+ ...alertRoute(basePath, plant),
40
+ ...timelineRoute(basePath, plant, opts.timelineOperator),
41
+ ...timelineStream(basePath, plant, time),
42
+ ...operationRoute(basePath, plant, opts.timelineOperator, opts.operationDecisions, opts.owners),
43
+ ...operationStream(basePath, plant, time),
44
+ ...heartbeatStream(basePath, time, opts.heartbeat),
45
+ ...(time.jump ? simulationRoute(basePath, time) : []),
46
+ ...extra
47
+ ];
48
+ return routes(routeList, { requestTimeoutMs: opts.requestTimeoutMs });
49
+ }
@@ -0,0 +1,19 @@
1
+ import operations from '../domain/operation/operations.js';
2
+ import pubsub from '../domain/shared/pubsub.js';
3
+
4
+ /**
5
+ * Wires operations persistence to domain operations with SSE pubsub.
6
+ *
7
+ * Optional kindSources inject non-PG kinds into listForMachine merges.
8
+ *
9
+ * @param {object} persistence - operations store with upsert, get, remove, listForMachine
10
+ * @param {object} [kindSources] - map of kind to { list(machineId, range) }
11
+ * @returns {object} operations with listForMachine, upsert, get, remove, and stream
12
+ *
13
+ * @example
14
+ * const ops = plantOperations(dataAccess.operations, { temp: temperaturePort });
15
+ * plant(shops, { operations: ops });
16
+ */
17
+ export default function plantOperations(persistence, kindSources) {
18
+ return operations(persistence, pubsub(), kindSources);
19
+ }
@@ -0,0 +1,107 @@
1
+ import http from 'http';
2
+ import { stompSource } from '@yarkivaev/source-to-sink';
3
+ import plantApi from './plantApi.js';
4
+ import pgAlerts from '../infrastructure/persistence/pg/alerts.js';
5
+ import postgresPool from '../infrastructure/persistence/postgresPool.js';
6
+ import stompAlerts from '../infrastructure/messaging/stomp/alerts/stompAlerts.js';
7
+ import stompTimelineSegments from '../infrastructure/messaging/stomp/stompTimelineSegments.js';
8
+ import userDecisions from '../infrastructure/messaging/stomp/userDecisions.js';
9
+ import { parseRequestTimeoutMs, virtualClock } from '@yarkivaev/simple-server';
10
+
11
+ function stompCollectorFactory(stompUrl, destination, credentials) {
12
+ return (collector) => {
13
+ const src = stompSource(stompUrl, destination, collector, credentials);
14
+ src.start();
15
+ return src;
16
+ };
17
+ }
18
+
19
+ function timelineBusesInPlant(p) {
20
+ const buses = {};
21
+ for (const area of Object.values(p.shops.get())) {
22
+ for (const [id, item] of Object.entries(area.machines.get())) {
23
+ if (item.timeline && item.timeline.bus) {
24
+ buses[id] = item.timeline.bus;
25
+ }
26
+ }
27
+ }
28
+ return buses;
29
+ }
30
+
31
+ function wireTimelineSegments(stomp, plant) {
32
+ if (!stomp || !stomp.url) {
33
+ return undefined;
34
+ }
35
+ const buses = timelineBusesInPlant(plant);
36
+ if (Object.keys(buses).length === 0) {
37
+ return undefined;
38
+ }
39
+ const credentials = { login: stomp.login, passcode: stomp.passcode, host: stomp.host };
40
+ return stompTimelineSegments(
41
+ stompCollectorFactory(stomp.url, '/exchange/scada.segments', credentials),
42
+ buses
43
+ );
44
+ }
45
+
46
+ async function initStomp(stomp, translations, requirePool) {
47
+ if (!stomp || !stomp.url) {
48
+ return {};
49
+ }
50
+ const pool = postgresPool({});
51
+ if (requirePool && !pool) {
52
+ throw new Error('SUPERVISOR_STATE_PG_URL is required for plant server alerts');
53
+ }
54
+ const credentials = { login: stomp.login, passcode: stomp.passcode, host: stomp.host };
55
+ let alerts;
56
+ if (pool) {
57
+ alerts = stompAlerts(pgAlerts(pool), stompCollectorFactory(stomp.url, '/exchange/scada.alerts', credentials), translations || {});
58
+ await alerts.init();
59
+ }
60
+ const decisions = userDecisions({
61
+ stompUrl: stomp.url,
62
+ login: stomp.login,
63
+ passcode: stomp.passcode,
64
+ host: stomp.host
65
+ });
66
+ return { alerts, userDecisions: decisions };
67
+ }
68
+
69
+ /**
70
+ * Generic HTTP plant server with optional STOMP alerts and user decisions.
71
+ *
72
+ * Multi-kind operations come from plant.operations built with kindSources
73
+ * (e.g. plantOperations(persistence, { temp })).
74
+ *
75
+ * @param {object} config - port, basePath, plantFactory, extraRoutes, translations, stomp, requirePool
76
+ * @returns {Promise<object>} server, plant, api, segments
77
+ *
78
+ * @example
79
+ * await plantServer({ port: 3000, basePath: '/api/v1', plantFactory, extraRoutes });
80
+ */
81
+ export default async function plantServer(config) {
82
+ const { alerts, userDecisions: decisions } = await initStomp(config.stomp, config.translations, config.requirePool);
83
+ const p = await config.plantFactory({ alerts, userDecisions: decisions });
84
+ const segments = wireTimelineSegments(config.stomp, p);
85
+ const clock = virtualClock(() => {
86
+ return new Date();
87
+ });
88
+ const requestTimeoutMs = parseRequestTimeoutMs(process.env.REQUEST_TIMEOUT_MS);
89
+ const basePath = config.basePath || '/api/v1';
90
+ const extra = config.extraRoutes ? config.extraRoutes(basePath, p, clock) : [];
91
+ const api = plantApi(basePath, p, {
92
+ clock,
93
+ heartbeat: config.heartbeat || 1000,
94
+ requestTimeoutMs,
95
+ extraRoutes: extra,
96
+ timelineOperator: config.timelineOperator,
97
+ operationDecisions: config.operationDecisions,
98
+ owners: config.owners
99
+ });
100
+ const server = http.createServer((req, res) => {
101
+ return api.handle(req, res);
102
+ });
103
+ await new Promise((resolve) => {
104
+ server.listen(config.port, resolve);
105
+ });
106
+ return { server, plant: p, api, segments };
107
+ }
@@ -0,0 +1,112 @@
1
+ import pubsub from '../domain/shared/pubsub.js';
2
+ import timeline from '../domain/timeline/timeline.js';
3
+ import pgTimeline from '../infrastructure/persistence/pg/timeline.js';
4
+ import memoryTimelineStore, { memoryTimelineRead } from '../infrastructure/persistence/memory/timeline.js';
5
+ import ownerTimeline from '../infrastructure/messaging/ownership/ownerTimeline.js';
6
+ import stompTimeline from '../infrastructure/messaging/stomp/timeline.js';
7
+
8
+ function parseStart(requestId) {
9
+ const raw = decodeURIComponent(String(requestId));
10
+ const start = new Date(raw);
11
+ if (Number.isNaN(start.getTime())) {
12
+ throw new RangeError(`Invalid request start time ${raw}`);
13
+ }
14
+ return start;
15
+ }
16
+
17
+ function resolvedStub(start, tags, properties) {
18
+ return {
19
+ name: '',
20
+ start_time: start,
21
+ end_time: start,
22
+ duration: 0,
23
+ tags: JSON.stringify(tags),
24
+ properties: JSON.stringify(properties)
25
+ };
26
+ }
27
+
28
+ function memoryTimelinePort(store, bus) {
29
+ const port = memoryTimelineRead(store, bus);
30
+ return {
31
+ list: port.list,
32
+ rowAt: port.rowAt,
33
+ pending: port.pending,
34
+ stream: port.stream,
35
+ bus,
36
+ retag(start, tags, properties, audit) {
37
+ const found = store.items.find((item) => {
38
+ return item.start_time.getTime() === start.getTime();
39
+ });
40
+ if (found) {
41
+ found.tags = tags;
42
+ found.properties = properties;
43
+ delete found.options;
44
+ }
45
+ bus.emit({ type: 'resolved', segment: resolvedStub(start, tags, properties), audit });
46
+ },
47
+ respond(requestId, body, audit) {
48
+ const start = parseStart(requestId);
49
+ const found = store.pending.find((item) => {
50
+ return item.start_time.getTime() === start.getTime();
51
+ });
52
+ if (found) {
53
+ found.resolved = true;
54
+ found.tags = body.tags;
55
+ found.properties = body.properties;
56
+ }
57
+ bus.emit({ type: 'resolved', request: { id: requestId, start }, audit });
58
+ return { id: requestId, ...body };
59
+ }
60
+ };
61
+ }
62
+
63
+ function writePort(name, decisions, owners) {
64
+ if (!owners) {
65
+ return stompTimeline(decisions, name);
66
+ }
67
+ return ownerTimeline((id) => {
68
+ return stompTimeline(decisions, id);
69
+ }, owners)(name);
70
+ }
71
+
72
+ /**
73
+ * Builds a machine timeline from PostgreSQL persistence and STOMP user decisions.
74
+ *
75
+ * Optional owners registry routes retag/respond to an edge HTTP plant API.
76
+ *
77
+ * @param {string} name - machine id
78
+ * @param {object} options - pool, userDecisions, optional owners
79
+ * @returns {object} timeline port with list, rowAt, pending, stream, retag, respond, bus
80
+ *
81
+ * @example
82
+ * const tl = shopWithTimeline('machine1', { pool, userDecisions, owners });
83
+ */
84
+ export default function shopWithTimeline(name, options) {
85
+ const { pool, userDecisions: decisions, owners } = options;
86
+ if (pool && decisions) {
87
+ const bus = pubsub();
88
+ const read = pgTimeline(pool, name);
89
+ const write = writePort(name, decisions, owners);
90
+ const port = timeline(read, bus);
91
+ return {
92
+ list: port.list,
93
+ rowAt: port.rowAt,
94
+ pending: port.pending,
95
+ stream: port.stream,
96
+ bus,
97
+ async retag(start, tags, properties, audit) {
98
+ await write.retag(start, tags, properties, audit);
99
+ bus.emit({ type: 'resolved', segment: resolvedStub(start, tags, properties), audit });
100
+ },
101
+ async respond(requestId, body, audit) {
102
+ const start = parseStart(requestId);
103
+ await write.respond(start, body.tags, body.properties || {}, audit);
104
+ bus.emit({ type: 'resolved', request: { id: requestId, start }, audit });
105
+ return { id: requestId, ...body };
106
+ }
107
+ };
108
+ }
109
+ const store = memoryTimelineStore();
110
+ const bus = pubsub();
111
+ return memoryTimelinePort(store, bus);
112
+ }
@@ -0,0 +1,78 @@
1
+ import operatorsFromPg from '../infrastructure/persistence/pg/operators.js';
2
+ import userDecisionsFromPg from '../infrastructure/persistence/pg/userDecisions.js';
3
+ import operatorRoute from '../infrastructure/http/plant/routes/operatorRoute.js';
4
+ import decisionRoute from '../infrastructure/http/plant/routes/decisionRoute.js';
5
+ import edgeOperatorCatalog from './edgeOperatorCatalog.js';
6
+
7
+ /**
8
+ * Central-only operator and user_decisions routes backed by Postgres.
9
+ *
10
+ * @param {string} basePath - plant API base path
11
+ * @param {object} sink - supervisor sink with pool and profile
12
+ * @returns {object} routes, provider, decisions
13
+ */
14
+ function centralOperatorRoutes(basePath, sink, owners) {
15
+ if (sink.sinkDbProfile !== 'central') {
16
+ return { routes: [], provider: undefined, decisions: undefined };
17
+ }
18
+ const provider = operatorsFromPg(sink.pool);
19
+ const decisions = userDecisionsFromPg(sink.pool);
20
+ return {
21
+ routes: [
22
+ ...operatorRoute(basePath, provider),
23
+ ...decisionRoute(basePath, decisions, owners)
24
+ ],
25
+ provider,
26
+ decisions
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Resolves operator HTTP catalog for central or edge sink profile.
32
+ *
33
+ * @param {string} basePath - plant API base path
34
+ * @param {object} sink - supervisor sink
35
+ * @param {object} env - process environment
36
+ * @param {object} [owners] - machineOwners registry for edge decision proxy
37
+ * @returns {object} routes, provider, decisions, sync
38
+ *
39
+ * @example
40
+ * siteOperatorCatalog('/api/v1', sink, process.env, owners);
41
+ */
42
+ export default function siteOperatorCatalog(basePath, sink, env, owners) {
43
+ if (sink.sinkDbProfile === 'central') {
44
+ return { ...centralOperatorRoutes(basePath, sink, owners), sync: undefined };
45
+ }
46
+ if (sink.sinkDbProfile === 'edge') {
47
+ const edge = edgeOperatorCatalog(basePath, env);
48
+ if (!sink.pool) {
49
+ return { ...edge, decisions: undefined };
50
+ }
51
+ const decisions = userDecisionsFromPg(sink.pool);
52
+ return {
53
+ ...edge,
54
+ routes: [...edge.routes, ...decisionRoute(basePath, decisions, owners)],
55
+ decisions
56
+ };
57
+ }
58
+ return { routes: [], sync: undefined, provider: undefined, decisions: undefined };
59
+ }
60
+
61
+ /**
62
+ * Picks plant operatorCatalog override or the built-in site catalog.
63
+ *
64
+ * @param {object} config - siteServer config, may include operatorCatalog factory
65
+ * @param {string} basePath - plant API base path
66
+ * @param {object} sink - supervisor sink
67
+ * @param {object} env - process environment
68
+ * @returns {object} routes, provider, decisions, sync
69
+ *
70
+ * @example
71
+ * buildSiteOperatorCatalog({ operatorCatalog: plantCatalog }, '/api/v1', sink, env);
72
+ */
73
+ export function buildSiteOperatorCatalog(config, basePath, sink, env) {
74
+ const factory = config && config.operatorCatalog
75
+ ? config.operatorCatalog
76
+ : siteOperatorCatalog;
77
+ return factory(basePath, sink, env, config && config.owners);
78
+ }