@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,51 @@
1
+ /**
2
+ * Shared alert record helpers for STOMP-backed alert history.
3
+ */
4
+
5
+ /**
6
+ * @param {object} translations - map of rule names to messages
7
+ * @param {string} name - rule identifier
8
+ * @returns {string} translated message or original name
9
+ */
10
+ function translate(translations, name) {
11
+ return translations[name] || name;
12
+ }
13
+
14
+ /**
15
+ * @param {object} fields - alert field values
16
+ * @returns {object} normalized alert
17
+ */
18
+ export function build({ id, message, timestamp, machine, acknowledged, name }) {
19
+ return {
20
+ id: String(id),
21
+ message,
22
+ timestamp: timestamp instanceof Date ? timestamp : new Date(timestamp),
23
+ object: machine,
24
+ event: undefined,
25
+ acknowledged,
26
+ name
27
+ };
28
+ }
29
+
30
+ /**
31
+ * @param {object} raw - STOMP frame
32
+ * @param {object} state - shared mutable state
33
+ * @param {object} translations - rule name map
34
+ */
35
+ export function consume(raw, state, translations) {
36
+ const parsed = JSON.parse(raw.payload);
37
+ if (parsed.status === 'pending') {
38
+ state.counter += 1;
39
+ const alert = build({ id: state.counter, message: translate(translations, parsed.name), timestamp: new Date(parsed.start * 1000), machine: parsed.machine, acknowledged: false, name: parsed.name });
40
+ state.items.push(alert);
41
+ state.bus.emit({ type: 'created', alert });
42
+ } else if (parsed.status === 'completed') {
43
+ state.items.forEach((existing, index) => {
44
+ if (existing.name === parsed.name && existing.object === parsed.machine && !existing.acknowledged) {
45
+ const replaced = build({ id: existing.id, message: existing.message, timestamp: existing.timestamp, machine: existing.object, acknowledged: true, name: existing.name });
46
+ state.items[index] = replaced;
47
+ state.bus.emit({ type: 'acknowledged', alert: replaced });
48
+ }
49
+ });
50
+ }
51
+ }
@@ -0,0 +1,80 @@
1
+ function timestamps(parsed) {
2
+ const startTime = new Date(parsed.start);
3
+ const endTime = new Date(parsed.end);
4
+ if (Number.isNaN(startTime.getTime()) || Number.isNaN(endTime.getTime())) {
5
+ throw new Error('Invalid segment timestamp');
6
+ }
7
+ return { startTime, endTime };
8
+ }
9
+
10
+ function mapTags(parsed) {
11
+ if (parsed.tags === undefined || parsed.tags === null) {
12
+ return undefined;
13
+ }
14
+ return typeof parsed.tags === 'string' ? parsed.tags : JSON.stringify(parsed.tags);
15
+ }
16
+
17
+ function mapProperties(parsed) {
18
+ if (parsed.properties === undefined) {
19
+ return undefined;
20
+ }
21
+ return typeof parsed.properties === 'string' ? parsed.properties : JSON.stringify(parsed.properties);
22
+ }
23
+
24
+ function segmentRow(parsed, startTime, endTime) {
25
+ const row = {
26
+ name: parsed.name,
27
+ start_time: startTime,
28
+ end_time: endTime,
29
+ duration: parsed.duration
30
+ };
31
+ if (parsed.options !== undefined) {
32
+ row.options = parsed.options;
33
+ }
34
+ const tags = mapTags(parsed);
35
+ if (tags !== undefined) {
36
+ row.tags = tags;
37
+ }
38
+ const properties = mapProperties(parsed);
39
+ if (properties !== undefined) {
40
+ row.properties = properties;
41
+ }
42
+ return row;
43
+ }
44
+
45
+ function consume(raw, buses) {
46
+ const parsed = JSON.parse(raw.payload);
47
+ const bus = buses[parsed.machine];
48
+ if (!bus) {
49
+ return;
50
+ }
51
+ const { startTime, endTime } = timestamps(parsed);
52
+ bus.emit({ type: 'created', segment: segmentRow(parsed, startTime, endTime) });
53
+ }
54
+
55
+ /**
56
+ * STOMP-to-timeline-bus bridge for segment messages.
57
+ *
58
+ * Subscribes to STOMP segment exchange and emits created events
59
+ * on per-machine timeline pubsub buses for SSE delivery.
60
+ *
61
+ * @param {function} source - factory(collector) returning { start(), stop() }
62
+ * @param {object} buses - map of machine IDs to pubsub buses with emit()
63
+ * @returns {object} bridge with stop() method
64
+ *
65
+ * @example
66
+ * const bridge = stompTimelineSegments(sourceFactory, { m1: bus });
67
+ * bridge.stop();
68
+ */
69
+ export default function stompTimelineSegments(source, buses) {
70
+ const subscription = source({
71
+ accept(raw) {
72
+ consume(raw, buses);
73
+ }
74
+ });
75
+ return {
76
+ stop() {
77
+ subscription.stop();
78
+ }
79
+ };
80
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * STOMP-backed timeline write port for one machine.
3
+ *
4
+ * @param {object} decisions - userDecisions with publish(machine, start, tags, properties, audit)
5
+ * @param {string} machineId - machine identifier
6
+ * @returns {object} timeline write port with retag and respond
7
+ *
8
+ * @example
9
+ * const stomp = stompTimeline(decisions, 'm1');
10
+ * await stomp.retag(new Date(), ['on'], {}, audit);
11
+ */
12
+ export default function stompTimeline(decisions, machineId) {
13
+ return {
14
+ async retag(start, tags, properties, audit) {
15
+ await decisions.publish(machineId, start, tags, properties, audit);
16
+ },
17
+ async respond(start, tags, properties, audit) {
18
+ await decisions.publish(machineId, start, tags, properties, audit);
19
+ }
20
+ };
21
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Builds a user_decisions STOMP payload for supervisor DecisionMessage.Resolve.
3
+ *
4
+ * @param {string} machine - machine id
5
+ * @param {Date} start - segment start time
6
+ * @param {string[]} tags - selected tags
7
+ * @param {object} properties - segment properties
8
+ * @param {object} audit - displayName, id, decidedAt
9
+ * @returns {object} JSON body for stompSend
10
+ */
11
+ export default function userDecisionBody(machine, start, tags, properties, audit) {
12
+ const epoch = start.getTime() / 1000;
13
+ const body = {
14
+ machine,
15
+ start: epoch,
16
+ user: audit.displayName,
17
+ tags: tags || [],
18
+ properties: properties || {},
19
+ decided_at: audit.decidedAt.getTime() / 1000
20
+ };
21
+ if (audit.id !== undefined && audit.id !== null) {
22
+ body.operator_id = audit.id;
23
+ }
24
+ return body;
25
+ }
@@ -0,0 +1,42 @@
1
+ import { stompSend } from '@yarkivaev/source-to-sink';
2
+ import userDecisionBody from './userDecisionBody.js';
3
+
4
+ const DEFAULT_DESTINATION = '/exchange/scada.user_decisions';
5
+
6
+ /**
7
+ * Publishes operator tag decisions to RabbitMQ for Scala supervisor.
8
+ *
9
+ * @param {object} config - publisher configuration
10
+ * @param {string} config.stompUrl - STOMP broker URL
11
+ * @param {string} [config.destination] - STOMP destination
12
+ * @param {string} [config.login] - STOMP login
13
+ * @param {string} [config.passcode] - STOMP passcode
14
+ * @param {string} [config.host] - STOMP vhost header
15
+ * @returns {object} publisher with publish(machine, start, tags, properties, audit)
16
+ */
17
+ export default function userDecisions(config) {
18
+ if (!config.stompUrl) {
19
+ throw new Error('stompUrl is required for user decision publisher');
20
+ }
21
+ const destination = config.destination || DEFAULT_DESTINATION;
22
+ const stompOptions = {
23
+ login: config.login,
24
+ passcode: config.passcode,
25
+ host: config.host
26
+ };
27
+ return {
28
+ /**
29
+ * Publishes one operator decision to STOMP.
30
+ *
31
+ * @param {string} machine - machine id
32
+ * @param {Date} start - segment start
33
+ * @param {string[]} tags - selected tags
34
+ * @param {object} properties - segment properties
35
+ * @param {object} audit - id, displayName, decidedAt
36
+ */
37
+ async publish(machine, start, tags, properties, audit) {
38
+ const body = userDecisionBody(machine, start, tags, properties, audit);
39
+ await stompSend(config.stompUrl, destination, body, stompOptions);
40
+ }
41
+ };
42
+ }
@@ -0,0 +1,64 @@
1
+ import operator from '../../domain/operator/operator.js';
2
+ import operatorExtras, { operatorIdentityKeys } from './operatorExtras.js';
3
+
4
+ function operatorFromJson(row) {
5
+ if (row.id === undefined || row.id === null) {
6
+ throw new Error('central operators item missing id');
7
+ }
8
+ if (!row.cardUid) {
9
+ throw new Error('central operators item missing cardUid');
10
+ }
11
+ if (!row.firstName) {
12
+ throw new Error('central operators item missing firstName');
13
+ }
14
+ if (!row.lastName) {
15
+ throw new Error('central operators item missing lastName');
16
+ }
17
+ if (!row.displayName) {
18
+ throw new Error('central operators item missing displayName');
19
+ }
20
+ return {
21
+ ...operator(row.id, row.cardUid, row.firstName, row.lastName, row.displayName),
22
+ ...operatorExtras(row, operatorIdentityKeys())
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Central plant operator catalog via HTTP (/api/v1/operators).
28
+ * Supports pull for sync, create for edge proxy writes, and enabled for registration flag.
29
+ *
30
+ * @param {object} client - HTTP client with getJson(path, query) and postJson(path, body)
31
+ * @param {string} basePath - plant API base path
32
+ * @returns {object} source with pull(), create(fields), enabled()
33
+ *
34
+ * @example
35
+ * const source = centralOperators(stateHttpClient({ baseUrl: 'http://central:3000' }), '/api/v1');
36
+ * const rows = await source.pull();
37
+ * const flag = await source.enabled();
38
+ */
39
+ export default function centralOperators(client, basePath) {
40
+ return {
41
+ async pull() {
42
+ const payload = await client.getJson(`${basePath}/operators`, {});
43
+ if (!payload || !Array.isArray(payload.items)) {
44
+ throw new Error(`central operators response missing items array from ${basePath}/operators`);
45
+ }
46
+ return payload.items.map((row) => {
47
+ return operatorFromJson(row);
48
+ });
49
+ },
50
+ async create(fields) {
51
+ const row = await client.postJson(`${basePath}/operators`, fields);
52
+ return operatorFromJson(row);
53
+ },
54
+ async enabled() {
55
+ const payload = await client.getJson(`${basePath}/operators/registration-enabled`, {});
56
+ if (!payload || typeof payload.enabled !== 'boolean') {
57
+ throw new Error(
58
+ `central registration-enabled response missing enabled boolean from ${basePath}/operators/registration-enabled`
59
+ );
60
+ }
61
+ return payload.enabled;
62
+ }
63
+ };
64
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Edge operators provider: local cache for reads, central proxy for create.
3
+ * Registration flag writes are central-only; edge returns 405 on permit.
4
+ *
5
+ * @param {object} cache - in-memory operators with list, replace, enabled, permit
6
+ * @param {object} [source] - centralOperators with create and pull, or undefined
7
+ * @returns {object} provider with list, create, enabled, permit
8
+ *
9
+ * @example
10
+ * const provider = edgeOperators(operators(), centralOperators(client, '/api/v1'));
11
+ * await provider.create({ cardUid: 'AB12', firstName: 'Ivan', lastName: 'Petrov', displayName: 'Ivan Petrov' });
12
+ */
13
+ export default function edgeOperators(cache, source) {
14
+ return {
15
+ list() {
16
+ return cache.list();
17
+ },
18
+ async create(fields) {
19
+ if (!source) {
20
+ const err = new Error('CENTRAL_PLANT_URL is required for edge operator create');
21
+ err.routeCode = 'SERVICE_UNAVAILABLE';
22
+ err.routeStatus = 503;
23
+ throw err;
24
+ }
25
+ const created = await source.create(fields);
26
+ cache.replace(await source.pull());
27
+ return created;
28
+ },
29
+ enabled() {
30
+ return cache.enabled();
31
+ },
32
+ permit() {
33
+ const err = new Error('edge operator registration flag write is not supported');
34
+ err.routeCode = 'METHOD_NOT_ALLOWED';
35
+ err.routeStatus = 405;
36
+ return Promise.reject(err);
37
+ }
38
+ };
39
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Resolves operator records by numeric id from a list provider.
3
+ *
4
+ * @param {object} provider - operators provider with list()
5
+ * @returns {object} resolver with resolve(operatorId)
6
+ *
7
+ * @example
8
+ * const lookup = operatorById(operatorsFromPg(pool));
9
+ * const row = await lookup.resolve(3);
10
+ */
11
+ export default function operatorById(provider) {
12
+ if (!provider || typeof provider.list !== 'function') {
13
+ throw new Error('Provider must have a list() method');
14
+ }
15
+ return {
16
+ /**
17
+ * Finds an operator by id or returns undefined when absent.
18
+ *
19
+ * @param {number|string} operatorId - operator primary key
20
+ * @returns {Promise<object|undefined>} operator or undefined
21
+ */
22
+ async resolve(operatorId) {
23
+ const id = Number(operatorId);
24
+ if (!Number.isFinite(id)) {
25
+ throw new RangeError(`Invalid operator id ${operatorId}`);
26
+ }
27
+ const rows = await provider.list();
28
+ return rows.find((row) => {
29
+ return row.id === id;
30
+ });
31
+ }
32
+ };
33
+ }
@@ -0,0 +1,41 @@
1
+ const IDENTITY = ['id', 'cardUid', 'firstName', 'lastName', 'displayName'];
2
+ const DRAFT = ['cardUid', 'firstName', 'lastName', 'displayName'];
3
+
4
+ /**
5
+ * Copies plant-owned operator fields that are outside the shared identity shape.
6
+ *
7
+ * @param {object} row - operator JSON or domain record
8
+ * @param {array} reserved - identity keys owned by scada
9
+ * @returns {object} extra own properties from row
10
+ *
11
+ * @example
12
+ * operatorExtras({ id: 1, cardUid: 'A', brigade: '2' }, ['id', 'cardUid']);
13
+ */
14
+ export default function operatorExtras(row, reserved) {
15
+ const skip = new Set(reserved);
16
+ const extra = {};
17
+ Object.keys(row).forEach((key) => {
18
+ if (!skip.has(key)) {
19
+ extra[key] = row[key];
20
+ }
21
+ });
22
+ return extra;
23
+ }
24
+
25
+ /**
26
+ * Reserved identity keys for operator JSON serialization.
27
+ *
28
+ * @returns {array} identity key names
29
+ */
30
+ export function operatorIdentityKeys() {
31
+ return IDENTITY.slice();
32
+ }
33
+
34
+ /**
35
+ * Reserved draft keys for operator create body parsing.
36
+ *
37
+ * @returns {array} draft key names
38
+ */
39
+ export function operatorDraftKeys() {
40
+ return DRAFT.slice();
41
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * In-memory operators provider for edge site-server.
3
+ * Implements list()/replace(items) for central sync and enabled()/permit(flag) for registration cache.
4
+ *
5
+ * @param {array} [seed] - optional initial operators
6
+ * @returns {object} provider with list, replace, enabled, permit
7
+ *
8
+ * @example
9
+ * const provider = operators();
10
+ * provider.replace([operator(1, 'card-1', 'Ivan', 'Petrov', 'Ivan Petrov')]);
11
+ * await provider.permit(true);
12
+ * const rows = await provider.list();
13
+ */
14
+ export default function operators(seed) {
15
+ let snapshot = seed ? seed.slice() : [];
16
+ let registration = false;
17
+ return {
18
+ list() {
19
+ return Promise.resolve(snapshot.slice());
20
+ },
21
+ replace(items) {
22
+ snapshot = items.slice();
23
+ },
24
+ enabled() {
25
+ return Promise.resolve(registration);
26
+ },
27
+ permit(flag) {
28
+ registration = flag === true;
29
+ return Promise.resolve(registration);
30
+ }
31
+ };
32
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * In-memory operators provider for tests and local wiring.
3
+ * Implements the operators provider port: list().
4
+ *
5
+ * @param {array} seed - operator records supplied by caller
6
+ * @returns {object} provider with async list()
7
+ *
8
+ * @example
9
+ * const provider = operatorsFromSeed([operator(1, 'card-1', 'Ivan', 'Petrov', 'Ivan Petrov')]);
10
+ * const rows = await provider.list();
11
+ */
12
+ export default function operatorsFromSeed(seed) {
13
+ return {
14
+ list() {
15
+ return Promise.resolve(seed);
16
+ }
17
+ };
18
+ }
@@ -0,0 +1,57 @@
1
+ const DEFAULT_INTERVAL_MS = 30000;
2
+
3
+ async function refresh(source, provider) {
4
+ const items = await source.pull();
5
+ provider.replace(items);
6
+ if (typeof source.enabled !== 'function' || typeof provider.permit !== 'function') {
7
+ return;
8
+ }
9
+ await provider.permit(await source.enabled());
10
+ }
11
+
12
+ /**
13
+ * Periodic sync of edge operators cache from central plant API.
14
+ * Default interval is 30s; keeps the last successful snapshot when central is unreachable.
15
+ * Also copies the registration enabled flag when source.enabled and provider.permit exist.
16
+ *
17
+ * @param {object} source - centralOperators with pull() and optional enabled()
18
+ * @param {object} provider - operators with replace(items) and optional permit(flag)
19
+ * @param {object} [options] - intervalMs for sync period
20
+ * @returns {object} sync with start() and stop()
21
+ *
22
+ * @example
23
+ * const sync = operatorsSync(source, provider, { intervalMs: 30000 });
24
+ * await sync.start();
25
+ */
26
+ export default function operatorsSync(source, provider, options) {
27
+ const intervalMs = options && options.intervalMs ? options.intervalMs : DEFAULT_INTERVAL_MS;
28
+ let timer;
29
+ let active = false;
30
+ async function tick() {
31
+ try {
32
+ await refresh(source, provider);
33
+ } catch {
34
+ /* next tick retries; stale snapshot remains */
35
+ }
36
+ }
37
+ return {
38
+ async start() {
39
+ if (active) {
40
+ return;
41
+ }
42
+ active = true;
43
+ await tick();
44
+ timer = setInterval(() => {
45
+ void tick();
46
+ }, intervalMs);
47
+ },
48
+ stop() {
49
+ if (!active) {
50
+ return;
51
+ }
52
+ active = false;
53
+ clearInterval(timer);
54
+ timer = undefined;
55
+ }
56
+ };
57
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * ClickHouse connection factory for SCADA metrics storage.
3
+ *
4
+ * Provides connection with query and insert methods for sensor data.
5
+ * When username is provided, skips schema initialization (for read-only users).
6
+ *
7
+ * @param {string} host - ClickHouse host
8
+ * @param {object} options - connection options
9
+ * @param {number} options.port - ClickHouse HTTP port (default: 8123)
10
+ * @param {string} options.username - ClickHouse username (optional)
11
+ * @param {string} options.password - ClickHouse password (optional)
12
+ * @returns {object} connection with query, insert, and close methods
13
+ *
14
+ * @example
15
+ * const conn = await clickhouseConnection('localhost', { username: 'readonly', password: 'secret' });
16
+ * const rows = await conn.query('SELECT * FROM scada.metrics WHERE topic = {topic:String}', { topic: 'm1/voltage' });
17
+ * await conn.close();
18
+ */
19
+ import { createClient } from '@clickhouse/client';
20
+
21
+ export default async function clickhouseConnection(host, options = {}) {
22
+ const port = options.port || 8123;
23
+ const {username} = options;
24
+ const {password} = options;
25
+ const config = { url: `http://${host}:${port}` };
26
+ if (username) {
27
+ config.username = username;
28
+ config.password = password || '';
29
+ }
30
+ const client = createClient(config);
31
+ if (!username) {
32
+ await client.command({ query: 'CREATE DATABASE IF NOT EXISTS scada' });
33
+ await client.command({
34
+ query: `CREATE TABLE IF NOT EXISTS scada.metrics (
35
+ topic String, ts DateTime64(3), value Float64
36
+ ) ENGINE = MergeTree() ORDER BY (topic, ts)`
37
+ });
38
+ }
39
+ return {
40
+ url() {
41
+ return `http://${host}:${port}`;
42
+ },
43
+ async query(sql, params = {}) {
44
+ const result = await client.query({
45
+ query: sql,
46
+ query_params: params,
47
+ format: 'JSONEachRow'
48
+ });
49
+ return result.json();
50
+ },
51
+ async insert(table, rows) {
52
+ await client.insert({ table, values: rows, format: 'JSONEachRow' });
53
+ },
54
+ async close() {
55
+ await client.close();
56
+ }
57
+ };
58
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Batched ClickHouse poll for many topic cursors.
3
+ *
4
+ * Issues a single IN-query for all topics so stream CPU stays proportional
5
+ * to poll pulses, not to the number of subscribed sensors.
6
+ *
7
+ * @param {object} connection - ClickHouse connection with query(sql, params)
8
+ * @param {Array<{topic: string, since: Date}>} cursors - per-topic watermarks
9
+ * @param {Date} until - exclusive upper bound
10
+ * @returns {Promise<Array<{topic: string, ts: string, value: number}>>} rows
11
+ *
12
+ * @example
13
+ * const rows = await pollTopicCursors(conn, [
14
+ * { topic: 'm1/voltage', since: lastTs }
15
+ * ], new Date());
16
+ */
17
+ function formatDateTime(date) {
18
+ return date.toISOString().replace('Z', '').replace('T', ' ');
19
+ }
20
+
21
+ export default function pollTopicCursors(connection, cursors, until) {
22
+ if (cursors.length === 0) {
23
+ return Promise.resolve([]);
24
+ }
25
+ const topics = cursors.map((cursor) => {
26
+ return cursor.topic;
27
+ });
28
+ let oldest = cursors[0].since;
29
+ cursors.forEach((cursor) => {
30
+ if (cursor.since.getTime() < oldest.getTime()) {
31
+ oldest = cursor.since;
32
+ }
33
+ });
34
+ return connection.query(
35
+ `SELECT topic, ts, value FROM scada.metrics
36
+ WHERE topic IN {topics:Array(String)}
37
+ AND ts > {since:DateTime64(3)}
38
+ AND ts <= {until:DateTime64(3)}
39
+ ORDER BY topic, ts
40
+ LIMIT {limit:UInt32}`,
41
+ {
42
+ topics,
43
+ since: formatDateTime(oldest),
44
+ until: formatDateTime(until),
45
+ limit: Math.max(100, topics.length * 100)
46
+ }
47
+ );
48
+ }