@yarkivaev/scada 1.5.0 → 2.3.46

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 +79 -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,22 @@
1
+ import operatorExtras, { operatorIdentityKeys } from '../../../operators/operatorExtras.js';
2
+
3
+ /**
4
+ * Maps an operator domain record to REST JSON shape.
5
+ * Plant-owned attributes beyond identity are passed through unchanged.
6
+ *
7
+ * @param {object} row - operator with id, cardUid, firstName, lastName, displayName
8
+ * @returns {object} JSON-serializable operator
9
+ *
10
+ * @example
11
+ * operatorJson({ id: 1, cardUid: 'dev-card-001', firstName: 'Ivan', lastName: 'Petrov', displayName: 'Ivan Petrov' });
12
+ */
13
+ export default function operatorJson(row) {
14
+ return {
15
+ id: row.id,
16
+ cardUid: row.cardUid,
17
+ firstName: row.firstName,
18
+ lastName: row.lastName,
19
+ displayName: row.displayName,
20
+ ...operatorExtras(row, operatorIdentityKeys())
21
+ };
22
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Maps a timeline segment row to REST JSON shape.
3
+ *
4
+ * @param {object} row - segment with start_time, end_time, duration
5
+ * @returns {object} JSON-serializable segment
6
+ *
7
+ * @example
8
+ * segmentJson({ name: 'on', start_time: new Date(), end_time: new Date(), duration: 60 });
9
+ */
10
+ export default function segmentJson(row) {
11
+ const mapped = {
12
+ name: row.name,
13
+ start: row.start_time.toISOString(),
14
+ end: row.duration === 0 ? new Date().toISOString() : row.end_time.toISOString(),
15
+ duration: row.duration
16
+ };
17
+ if (row.options) {
18
+ mapped.options = row.options;
19
+ }
20
+ if (row.tags) {
21
+ mapped.tags = row.tags;
22
+ }
23
+ if (row.properties) {
24
+ mapped.properties = row.properties;
25
+ }
26
+ return mapped;
27
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Stamps operator audit into an operation payload and builds decision rows.
3
+ *
4
+ * @example
5
+ * const next = stampPayload(payload, audit);
6
+ * const row = decisionRow(machine, item, audit, 'create');
7
+ */
8
+
9
+ /**
10
+ * Copies payload and adds operator display fields from audit.
11
+ *
12
+ * @param {*} payload - original operation payload
13
+ * @param {object} audit - { id, displayName, decidedAt }
14
+ * @returns {object} stamped payload object
15
+ */
16
+ export function stampPayload(payload, audit) {
17
+ const base = payload && typeof payload === 'object' && !Array.isArray(payload)
18
+ ? { ...payload }
19
+ : { value: payload };
20
+ base.operator = audit.displayName;
21
+ base.decided_at = audit.decidedAt.toISOString();
22
+ if (audit.id !== undefined && audit.id !== null) {
23
+ base.operator_id = audit.id;
24
+ }
25
+ return base;
26
+ }
27
+
28
+ /**
29
+ * Builds a user_decisions insert row for an operation write.
30
+ *
31
+ * @param {string} machine - machine id
32
+ * @param {object} item - operation with key, kind, occurred_at, payload
33
+ * @param {object} audit - resolved operator audit
34
+ * @param {string} verb - create | update | delete
35
+ * @returns {object} insert row for userDecisions.insert
36
+ */
37
+ export function decisionRow(machine, item, audit, verb) {
38
+ return {
39
+ machine,
40
+ startTime: item.occurred_at instanceof Date
41
+ ? item.occurred_at
42
+ : new Date(item.occurred_at),
43
+ username: audit.displayName,
44
+ operatorId: audit.id,
45
+ decidedAt: audit.decidedAt,
46
+ payload: {
47
+ kind: 'operation_op',
48
+ verb,
49
+ key: item.key,
50
+ operation_kind: item.kind,
51
+ occurred_at: item.occurred_at instanceof Date
52
+ ? item.occurred_at.toISOString()
53
+ : new Date(item.occurred_at).toISOString()
54
+ }
55
+ };
56
+ }
@@ -0,0 +1,93 @@
1
+ import { errorResponse, jsonResponse, pagination, route } from '@yarkivaev/simple-server';
2
+
3
+ /**
4
+ * Alert routes factory.
5
+ * Creates routes for GET and PATCH /machines/:machineId/alerts.
6
+ *
7
+ * @param {string} basePath - base URL path
8
+ * @param {object} plant - plant domain object from scada package
9
+ * @returns {array} array of route objects
10
+ *
11
+ * @example
12
+ * const routes = alertRoute('/api/v1', plant);
13
+ */
14
+ export default function alertRoute(basePath, plant) {
15
+ function find(id) {
16
+ for (const shop of Object.values(plant.shops.get())) {
17
+ const machine = shop.machines.get()[id];
18
+ if (machine) {
19
+ return machine;
20
+ }
21
+ }
22
+ return undefined;
23
+ }
24
+ function findAlert(alertId) {
25
+ for (const shop of Object.values(plant.shops.get())) {
26
+ for (const machine of Object.values(shop.machines.get())) {
27
+ const alert = machine.alerts().find((a) => {
28
+ return a.id === alertId;
29
+ });
30
+ if (alert) {
31
+ return alert;
32
+ }
33
+ }
34
+ }
35
+ return undefined;
36
+ }
37
+ return [
38
+ route(
39
+ 'GET',
40
+ `${basePath}/machines/:machineId/alerts`,
41
+ (req, res, params, query) => {
42
+ const machine = find(params.machineId);
43
+ if (!machine) {
44
+ jsonResponse({ items: [], page: 1, size: 10, total: 0 }).send(res);
45
+ return;
46
+ }
47
+ const page = query.page ? parseInt(query.page, 10) : 1;
48
+ const size = query.size ? parseInt(query.size, 10) : 10;
49
+ let alerts = machine.alerts();
50
+ if (query.acknowledged === 'true') {
51
+ alerts = alerts.filter((a) => {
52
+ return a.acknowledged === true;
53
+ });
54
+ } else if (query.acknowledged === 'false') {
55
+ alerts = alerts.filter((a) => {
56
+ return a.acknowledged === false;
57
+ });
58
+ }
59
+ const mapped = alerts.map((a) => {
60
+ return { id: a.id, message: a.message, timestamp: a.timestamp.toISOString(), object: a.object, acknowledged: a.acknowledged, name: a.name };
61
+ });
62
+ const paginated = pagination(page, size, mapped).result();
63
+ jsonResponse({ items: paginated.items, page: paginated.page, size: paginated.size, total: paginated.total }).send(res);
64
+ }
65
+ ),
66
+ route(
67
+ 'PATCH',
68
+ `${basePath}/machines/:machineId/alerts/:alertId`,
69
+ (req, res, params) => {
70
+ let body = '';
71
+ req.on('data', (chunk) => {
72
+ body += chunk;
73
+ });
74
+ req.on('end', () => {
75
+ const changes = JSON.parse(body);
76
+ const alert = findAlert(params.alertId);
77
+ if (!alert) {
78
+ errorResponse(
79
+ 'NOT_FOUND',
80
+ `Alert '${params.alertId}' not found`,
81
+ 404
82
+ ).send(res);
83
+ return;
84
+ }
85
+ if (changes.acknowledged === true && !alert.acknowledged) {
86
+ alert.acknowledge();
87
+ }
88
+ jsonResponse({ id: alert.id, message: alert.message, timestamp: alert.timestamp.toISOString(), object: alert.object, acknowledged: true, name: alert.name }).send(res);
89
+ });
90
+ }
91
+ )
92
+ ];
93
+ }
@@ -0,0 +1,21 @@
1
+ import { jsonResponse, route } from '@yarkivaev/simple-server';
2
+ import tagCatalog from '../../../catalog/tagCatalog.js';
3
+
4
+ /**
5
+ * Tag catalog HTTP routes for HMI label/cascade lookup.
6
+ *
7
+ * @param {string} basePath - base URL path
8
+ * @param {object} [catalog] - optional preloaded catalog
9
+ * @returns {array} route objects
10
+ *
11
+ * @example
12
+ * catalogRoute('/api/v1');
13
+ */
14
+ export default function catalogRoute(basePath, catalog) {
15
+ const loaded = catalog || tagCatalog();
16
+ return [
17
+ route('GET', `${basePath}/tag-catalog`, (req, res) => {
18
+ jsonResponse({ items: loaded.entries() }).send(res);
19
+ })
20
+ ];
21
+ }
@@ -0,0 +1,60 @@
1
+ import decisionJson from '../json/decisionJson.js';
2
+ import httpOperations from '../../../messaging/ownership/httpOperations.js';
3
+ import { errorResponse, jsonResponse, route } from '@yarkivaev/simple-server';
4
+
5
+ function edgePort(owners, machineId) {
6
+ if (!owners || typeof owners.resolve !== 'function') {
7
+ return undefined;
8
+ }
9
+ const owner = owners.resolve(machineId);
10
+ if (!owner || owner.kind !== 'edge') {
11
+ return undefined;
12
+ }
13
+ return httpOperations(owner, machineId);
14
+ }
15
+
16
+ /**
17
+ * Segment and operation user_decisions history routes.
18
+ *
19
+ * Operation chronology for edge-owned machines is proxied to the owner API.
20
+ *
21
+ * @param {string} basePath - base URL path
22
+ * @param {object} catalog - user decisions port with list and listByKey
23
+ * @param {object} [owners] - machineOwners registry
24
+ * @returns {array} route objects
25
+ *
26
+ * @example
27
+ * decisionRoute('/api/v1', userDecisionsFromPg(pool), owners);
28
+ */
29
+ export default function decisionRoute(basePath, catalog, owners) {
30
+ return [
31
+ route('GET', `${basePath}/machines/:machineId/segments/:start/decisions`, async (req, res, params) => {
32
+ const start = new Date(decodeURIComponent(params.start));
33
+ if (Number.isNaN(start.getTime())) {
34
+ errorResponse('BAD_REQUEST', `Invalid segment start '${params.start}'`, 400).send(res);
35
+ return;
36
+ }
37
+ const rows = await catalog.list(decodeURIComponent(params.machineId), start);
38
+ jsonResponse({ items: rows.map(decisionJson) }).send(res);
39
+ }),
40
+ route('GET', `${basePath}/machines/:machineId/operations/:key/decisions`, async (req, res, params) => {
41
+ const machineId = decodeURIComponent(params.machineId);
42
+ const key = decodeURIComponent(params.key);
43
+ if (!key) {
44
+ errorResponse('BAD_REQUEST', 'operation key is required', 400).send(res);
45
+ return;
46
+ }
47
+ const port = edgePort(owners, machineId);
48
+ if (port) {
49
+ jsonResponse({ items: await port.decisions(key) }).send(res);
50
+ return;
51
+ }
52
+ if (typeof catalog.listByKey !== 'function') {
53
+ jsonResponse({ items: [] }).send(res);
54
+ return;
55
+ }
56
+ const rows = await catalog.listByKey(machineId, key);
57
+ jsonResponse({ items: rows.map(decisionJson) }).send(res);
58
+ })
59
+ ];
60
+ }
@@ -0,0 +1,35 @@
1
+ import machineInPlant from '../../../../application/machineInPlant.js';
2
+ import { errorResponse, jsonResponse, route } from '@yarkivaev/simple-server';
3
+
4
+ /**
5
+ * Machine list and info routes.
6
+ *
7
+ * @param {string} basePath - base URL path
8
+ * @param {object} plant - plant domain object
9
+ * @returns {array} route objects
10
+ *
11
+ * @example
12
+ * machineRoute('/api/v1', plant);
13
+ */
14
+ export default function machineRoute(basePath, plant) {
15
+ function all() {
16
+ return Object.values(plant.shops.get()).flatMap((area) => {
17
+ return Object.values(area.machines.get()).map((item) => {
18
+ return { id: item.name(), name: item.name() };
19
+ });
20
+ });
21
+ }
22
+ return [
23
+ route('GET', `${basePath}/machines`, (req, res) => {
24
+ jsonResponse({ items: all() }).send(res);
25
+ }),
26
+ route('GET', `${basePath}/machines/:machineId`, (req, res, params) => {
27
+ const result = machineInPlant(plant, params.machineId);
28
+ if (!result) {
29
+ errorResponse('NOT_FOUND', `Machine '${params.machineId}' not found`, 404).send(res);
30
+ return;
31
+ }
32
+ jsonResponse({ id: result.machine.name(), name: result.machine.name() }).send(res);
33
+ })
34
+ ];
35
+ }
@@ -0,0 +1,60 @@
1
+ import machineInPlant from '../../../../application/machineInPlant.js';
2
+ import { jsonResponse, route, timeExpression } from '@yarkivaev/simple-server';
3
+
4
+ /**
5
+ * Measurement routes factory.
6
+ * Creates route for GET /machines/:machineId/measurements.
7
+ *
8
+ * @param {string} basePath - base URL path
9
+ * @param {object} plant - plant domain object from scada package
10
+ * @param {function} clock - time provider
11
+ * @returns {array} array of route objects
12
+ *
13
+ * @example
14
+ * const routes = measurementRoute('/api/v1', plant, clock);
15
+ */
16
+ export default function measurementRoute(basePath, plant, clock) {
17
+ function beginning() {
18
+ return new Date(clock().getTime() - 30 * 24 * 60 * 60 * 1000);
19
+ }
20
+ function find(id) {
21
+ const result = machineInPlant(plant, id);
22
+ if (result) {
23
+ return result.machine;
24
+ }
25
+ return undefined;
26
+ }
27
+ return [
28
+ route(
29
+ 'GET',
30
+ `${basePath}/machines/:machineId/measurements`,
31
+ async (req, res, params, query) => {
32
+ const machine = find(params.machineId);
33
+ if (!machine) {
34
+ jsonResponse({ items: [] }).send(res);
35
+ return;
36
+ }
37
+ const requested = query.keys ? query.keys.split(',') : Object.keys(machine.sensors);
38
+ const keys = requested.filter((key) => {return machine.sensors[key]});
39
+ const fromExpr = query.from || 'now-1M';
40
+ const toExpr = query.to || 'now';
41
+ const from = timeExpression(fromExpr, clock, beginning).resolve();
42
+ const to = timeExpression(toExpr, clock, beginning).resolve();
43
+ const step = query.step ? parseInt(query.step, 10) * 1000 : 1000;
44
+ const range = { start: from, end: to };
45
+ const promises = keys.map(async (key) => {
46
+ const sensor = machine.sensors[key];
47
+ const measurements = await sensor.measurements(range, step);
48
+ const unit = measurements.length > 0 ? measurements[0].unit : '';
49
+ const values = measurements.map((row) => {return {
50
+ timestamp: row.timestamp.toISOString(),
51
+ value: row.value
52
+ }});
53
+ return { key, name: sensor.name(), unit, values };
54
+ });
55
+ const items = await Promise.all(promises);
56
+ jsonResponse({ items }).send(res);
57
+ }
58
+ )
59
+ ];
60
+ }
@@ -0,0 +1,79 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ function reject(code, message, status) {
4
+ const err = new Error(message);
5
+ err.routeCode = code;
6
+ err.routeStatus = status;
7
+ return err;
8
+ }
9
+
10
+ /**
11
+ * Builds a create draft from a parsed POST body.
12
+ *
13
+ * @param {string} machineId - machine id
14
+ * @param {object} parsed - JSON body
15
+ * @returns {object} operation draft
16
+ */
17
+ export function draftFromBody(machineId, parsed) {
18
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
19
+ throw reject('BAD_REQUEST', 'operation body must be a JSON object', 400);
20
+ }
21
+ if (typeof parsed.kind !== 'string' || parsed.kind.length === 0) {
22
+ throw reject('BAD_REQUEST', 'kind is required', 400);
23
+ }
24
+ if (parsed.payload === undefined) {
25
+ throw reject('BAD_REQUEST', 'payload is required', 400);
26
+ }
27
+ const occurred = parsed.occurred_at === undefined ? new Date() : new Date(parsed.occurred_at);
28
+ if (Number.isNaN(occurred.getTime())) {
29
+ throw reject('BAD_REQUEST', 'occurred_at must be a valid timestamp', 400);
30
+ }
31
+ const key = parsed.key === undefined || parsed.key === null
32
+ ? `${parsed.kind}:${machineId}:${randomUUID()}`
33
+ : parsed.key;
34
+ if (typeof key !== 'string' || key.length === 0) {
35
+ throw reject('BAD_REQUEST', 'key must be a non-empty string', 400);
36
+ }
37
+ return {
38
+ machine: machineId,
39
+ kind: parsed.kind,
40
+ key,
41
+ occurred_at: occurred,
42
+ payload: parsed.payload
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Builds an update draft from existing row and parsed PUT body.
48
+ *
49
+ * @param {string} machineId - machine id
50
+ * @param {string} key - operation key
51
+ * @param {object} existing - current row
52
+ * @param {object} parsed - JSON body
53
+ * @returns {object} operation draft
54
+ */
55
+ export function draftFromUpdate(machineId, key, existing, parsed) {
56
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
57
+ throw reject('BAD_REQUEST', 'operation body must be a JSON object', 400);
58
+ }
59
+ if (parsed.payload === undefined) {
60
+ throw reject('BAD_REQUEST', 'payload is required', 400);
61
+ }
62
+ const kind = parsed.kind === undefined ? existing.kind : parsed.kind;
63
+ if (typeof kind !== 'string' || kind.length === 0) {
64
+ throw reject('BAD_REQUEST', 'kind must be a non-empty string', 400);
65
+ }
66
+ const occurred = parsed.occurred_at === undefined
67
+ ? new Date(existing.occurred_at)
68
+ : new Date(parsed.occurred_at);
69
+ if (Number.isNaN(occurred.getTime())) {
70
+ throw reject('BAD_REQUEST', 'occurred_at must be a valid timestamp', 400);
71
+ }
72
+ return {
73
+ machine: machineId,
74
+ kind,
75
+ key,
76
+ occurred_at: occurred,
77
+ payload: parsed.payload
78
+ };
79
+ }
@@ -0,0 +1,80 @@
1
+ import machineInPlant from '../../../../application/machineInPlant.js';
2
+ import operationJson from '../json/operationJson.js';
3
+ import timelineOperator from '../timelineOperator.js';
4
+ import operationWrites from './operationWrites.js';
5
+ import { jsonResponse, route } from '@yarkivaev/simple-server';
6
+
7
+ function parseRange(query) {
8
+ const range = {};
9
+ if (query.from) {
10
+ range.from = new Date(query.from);
11
+ }
12
+ if (query.to) {
13
+ range.to = new Date(query.to);
14
+ }
15
+ return range;
16
+ }
17
+
18
+ function resolveKinds(query) {
19
+ if (query.kinds) {
20
+ return query.kinds.split(',').map((token) => {
21
+ return token.trim();
22
+ }).filter((token) => {
23
+ return token.length > 0;
24
+ });
25
+ }
26
+ if (query.kind) {
27
+ return [query.kind];
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ /**
33
+ * Operations REST routes for machine-scoped reads and writes.
34
+ *
35
+ * Writes resolve operator via timelineOperator and stamp payload.operator.
36
+ * Edge-owned machines proxy create/update/delete to the owning plant API
37
+ * (no local upsert or decision insert). Optional owners registry mirrors timeline.
38
+ *
39
+ * @param {string} basePath - base URL path
40
+ * @param {object} plant - plant domain object
41
+ * @param {object} [operatorOptions] - timelineOperator options
42
+ * @param {object} [decisions] - userDecisions port with insert
43
+ * @param {object} [owners] - machineOwners registry
44
+ * @returns {array} route objects
45
+ *
46
+ * @example
47
+ * operationRoute('/api/v1', plant, timelineOperatorOpts, decisions, owners);
48
+ */
49
+ export default function operationRoute(basePath, plant, operatorOptions, decisions, owners) {
50
+ const writes = operationWrites({
51
+ plant,
52
+ gate: timelineOperator(operatorOptions),
53
+ decisions,
54
+ owners
55
+ });
56
+ return [
57
+ route('GET', `${basePath}/machines/:machineId/operations`, async (req, res, params, query) => {
58
+ const result = machineInPlant(plant, params.machineId);
59
+ if (!result || !plant.operations) {
60
+ jsonResponse({ items: [] }).send(res);
61
+ return;
62
+ }
63
+ const rows = await plant.operations.listForMachine(
64
+ params.machineId,
65
+ resolveKinds(query),
66
+ parseRange(query)
67
+ );
68
+ jsonResponse({ items: rows.map(operationJson) }).send(res);
69
+ }),
70
+ route('POST', `${basePath}/machines/:machineId/operations`, async (req, res, params) => {
71
+ await writes.writeCreate(params.machineId, req, res);
72
+ }),
73
+ route('PUT', `${basePath}/machines/:machineId/operations/:key`, async (req, res, params) => {
74
+ await writes.writeUpdate(params.machineId, decodeURIComponent(params.key), req, res);
75
+ }),
76
+ route('DELETE', `${basePath}/machines/:machineId/operations/:key`, async (req, res, params) => {
77
+ await writes.writeDelete(params.machineId, decodeURIComponent(params.key), req, res);
78
+ })
79
+ ];
80
+ }