@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
@@ -4,7 +4,8 @@
4
4
  * Reads sensor measurements from scada.metrics table
5
5
  * and provides real-time streaming via polling.
6
6
  * Supports time-based downsampling using ClickHouse aggregation.
7
- * Returns value of 0 for missing data to comply with no-null-return principle.
7
+ * Live streams share a connection-level batch hub so many sensors
8
+ * cost one ClickHouse query per tick instead of one query per sensor.
8
9
  *
9
10
  * @param {object} connection - ClickHouse connection with query method
10
11
  * @param {string} topic - Metric topic in format '{machine}/{sensor}'
@@ -13,17 +14,18 @@
13
14
  * @returns {object} sensor with name, current, measurements and stream methods
14
15
  *
15
16
  * @example
16
- * const sensor = clickhouseSensor(conn, 'icht1/voltage', 'Voltage', 'V');
17
+ * const sensor = clickhouseSensor(conn, 'm1/voltage', 'Voltage', 'V');
17
18
  * sensor.name(); // 'Voltage'
18
19
  * await sensor.current(); // { found: true, timestamp, value, unit } or { found: false }
19
20
  * await sensor.measurements({ start, end }, 60000); // downsampled to 1-minute intervals
20
21
  * sensor.stream(since, 1000, callback); // live stream
21
22
  */
23
+ import clickhouseStreamHub from './streamHub.js';
24
+
22
25
  function formatDateTime(date) {
23
26
  return date.toISOString().replace('Z', '').replace('T', ' ');
24
27
  }
25
28
 
26
- // eslint-disable-next-line max-lines-per-function
27
29
  export default function clickhouseSensor(connection, topic, displayName, unit) {
28
30
  return {
29
31
  name() {
@@ -64,30 +66,10 @@ export default function clickhouseSensor(connection, topic, displayName, unit) {
64
66
  });
65
67
  },
66
68
  stream(since, step, callback, clock) {
67
- const time = clock || (() => { return new Date(); });
68
- let lastTs = since;
69
- const timer = setInterval(async () => {
70
- try {
71
- const rows = await connection.query(
72
- `SELECT ts, value FROM scada.metrics
73
- WHERE topic = {topic:String} AND ts > {since:DateTime64(3)} AND ts <= {until:DateTime64(3)}
74
- ORDER BY ts LIMIT 100`,
75
- { topic, since: formatDateTime(lastTs), until: formatDateTime(time()) }
76
- );
77
- rows.forEach((row) => {
78
- const timestamp = new Date(`${row.ts}Z`);
79
- callback({ timestamp, value: row.value, unit });
80
- lastTs = timestamp;
81
- });
82
- } catch (err) {
83
- console.error(`Stream query failed for ${topic} on ${connection.url()}:`, err.message); // eslint-disable-line no-console
84
- }
85
- }, step);
86
- return {
87
- cancel() {
88
- clearInterval(timer);
89
- }
90
- };
69
+ return clickhouseStreamHub(connection).watch(topic, since, step, callback, {
70
+ unit,
71
+ clock
72
+ });
91
73
  }
92
74
  };
93
75
  }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Shared ClickHouse stream hub.
3
+ *
4
+ * Coalesces many topic watchers on one connection into one batched poll per
5
+ * step interval, cutting ClickHouse CPU from O(sensors) to O(pulses).
6
+ *
7
+ * @example
8
+ * const hub = clickhouseStreamHub(connection);
9
+ * const sub = hub.watch('m1/V', since, 1000, onRow, { unit: 'V', clock });
10
+ * sub.cancel();
11
+ */
12
+ import pollTopicCursors from './pollTopicCursors.js';
13
+
14
+ const hubs = new WeakMap();
15
+
16
+ function defaultSchedule(step, fn) {
17
+ const timer = setInterval(fn, step);
18
+ return () => {
19
+ clearInterval(timer);
20
+ };
21
+ }
22
+
23
+ function parseTimestamp(ts) {
24
+ return new Date(`${ts}Z`);
25
+ }
26
+
27
+ function bucketKey(step) {
28
+ return String(step);
29
+ }
30
+
31
+ function createBucket(hub, step, clock) {
32
+ return {
33
+ step,
34
+ clock,
35
+ watchers: new Map(),
36
+ stop: hub.schedule(step, () => {
37
+ return hub.pulse(step);
38
+ })
39
+ };
40
+ }
41
+
42
+ function addWatcher(bucket, topic, watcher) {
43
+ if (!bucket.watchers.has(topic)) {
44
+ bucket.watchers.set(topic, new Set());
45
+ }
46
+ bucket.watchers.get(topic).add(watcher);
47
+ }
48
+
49
+ function removeWatcher(bucket, topic, watcher) {
50
+ const group = bucket.watchers.get(topic);
51
+ if (!group) {
52
+ return;
53
+ }
54
+ group.delete(watcher);
55
+ if (group.size === 0) {
56
+ bucket.watchers.delete(topic);
57
+ }
58
+ }
59
+
60
+ function cursorsOf(bucket) {
61
+ const oldest = new Map();
62
+ bucket.watchers.forEach((group, topic) => {
63
+ group.forEach((watcher) => {
64
+ const prev = oldest.get(topic);
65
+ if (!prev || watcher.lastTs.getTime() < prev.getTime()) {
66
+ oldest.set(topic, watcher.lastTs);
67
+ }
68
+ });
69
+ });
70
+ const cursors = [];
71
+ oldest.forEach((since, topic) => {
72
+ cursors.push({ topic, since });
73
+ });
74
+ return cursors;
75
+ }
76
+
77
+ function dispatch(bucket, rows) {
78
+ rows.forEach((row) => {
79
+ const group = bucket.watchers.get(row.topic);
80
+ if (!group) {
81
+ return;
82
+ }
83
+ const timestamp = parseTimestamp(row.ts);
84
+ group.forEach((watcher) => {
85
+ if (timestamp.getTime() <= watcher.lastTs.getTime()) {
86
+ return;
87
+ }
88
+ watcher.callback({ timestamp, value: row.value, unit: watcher.unit });
89
+ watcher.lastTs = timestamp;
90
+ });
91
+ });
92
+ }
93
+
94
+ function buildHub(connection, schedule) {
95
+ const buckets = new Map();
96
+ const hub = {
97
+ schedule,
98
+ async pulse(step) {
99
+ const bucket = buckets.get(bucketKey(step));
100
+ if (!bucket || bucket.watchers.size === 0) {
101
+ return;
102
+ }
103
+ try {
104
+ const rows = await pollTopicCursors(
105
+ connection,
106
+ cursorsOf(bucket),
107
+ bucket.clock()
108
+ );
109
+ dispatch(bucket, rows);
110
+ } catch (err) {
111
+ console.error( // eslint-disable-line no-console
112
+ `Stream batch failed on ${connection.url()}:`,
113
+ err.message
114
+ );
115
+ }
116
+ },
117
+ watch(topic, since, step, callback, options) {
118
+ const key = bucketKey(step);
119
+ if (!buckets.has(key)) {
120
+ buckets.set(key, createBucket(hub, step, options.clock || (() => {
121
+ return new Date();
122
+ })));
123
+ }
124
+ const bucket = buckets.get(key);
125
+ const watcher = { lastTs: since, callback, unit: options.unit };
126
+ addWatcher(bucket, topic, watcher);
127
+ return {
128
+ cancel() {
129
+ removeWatcher(bucket, topic, watcher);
130
+ if (bucket.watchers.size === 0) {
131
+ bucket.stop();
132
+ buckets.delete(key);
133
+ }
134
+ }
135
+ };
136
+ }
137
+ };
138
+ return hub;
139
+ }
140
+
141
+ /**
142
+ * Creates a stream hub for connection, replacing any previous hub.
143
+ *
144
+ * @param {object} connection - shared ClickHouse connection
145
+ * @param {function} [schedule] - (step, fn) => cancel, for tests
146
+ * @returns {object} hub with watch(topic, since, step, callback, options)
147
+ */
148
+ export function createClickhouseStreamHub(connection, schedule) {
149
+ const hub = buildHub(connection, schedule || defaultSchedule);
150
+ hubs.set(connection, hub);
151
+ return hub;
152
+ }
153
+
154
+ /**
155
+ * Returns the shared hub for connection, creating one if needed.
156
+ *
157
+ * @param {object} connection - shared ClickHouse connection
158
+ * @returns {object} hub with watch(topic, since, step, callback, options)
159
+ */
160
+ export default function clickhouseStreamHub(connection) {
161
+ if (!hubs.has(connection)) {
162
+ return createClickhouseStreamHub(connection, defaultSchedule);
163
+ }
164
+ return hubs.get(connection);
165
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * In-memory alert hydration port for tests and local runs.
3
+ *
4
+ * @param {object} store - shared mutable store with alerts array
5
+ * @returns {object} alerts port with listUnacknowledged
6
+ */
7
+ export default function alertsStateMemory(store) {
8
+ return {
9
+ listUnacknowledged(filters) {
10
+ let rows = store.alerts.filter((row) => {
11
+ return row.acknowledged === false;
12
+ });
13
+ if (filters.machine) {
14
+ rows = rows.filter((row) => {
15
+ return row.machine === filters.machine;
16
+ });
17
+ }
18
+ return rows;
19
+ }
20
+ };
21
+ }
@@ -0,0 +1,98 @@
1
+ function parseJsonField(raw) {
2
+ if (!raw) {
3
+ return null;
4
+ }
5
+ return JSON.parse(raw);
6
+ }
7
+
8
+ function segmentItem(row) {
9
+ if (!row) {
10
+ return null;
11
+ }
12
+ return {
13
+ machine: row.machine,
14
+ name: row.name,
15
+ start: new Date(row.start_time).getTime() / 1000,
16
+ end: new Date(row.end_time).getTime() / 1000,
17
+ duration: row.duration,
18
+ tags: parseJsonField(row.tags),
19
+ options: parseJsonField(row.options),
20
+ properties: parseJsonField(row.properties)
21
+ };
22
+ }
23
+
24
+ function segmentAt(store, machineId, startEpoch) {
25
+ const startMs = startEpoch * 1000;
26
+ const row = store.segments.find((item) => {
27
+ return item.machine === machineId && new Date(item.start_time).getTime() === startMs;
28
+ });
29
+ return segmentItem(row);
30
+ }
31
+
32
+ function replayCursorFor(store, machineId) {
33
+ const closed = store.segments.filter((row) => {
34
+ return row.machine === machineId && Number(row.duration) > 0;
35
+ }).map((row) => {
36
+ return new Date(row.end_time).getTime() / 1000;
37
+ });
38
+ const pending = store.segments.filter((row) => {
39
+ return row.machine === machineId && Number(row.duration) === 0;
40
+ }).map((row) => {
41
+ return new Date(row.start_time).getTime() / 1000;
42
+ });
43
+ const closedMax = closed.length ? Math.max(...closed) : null;
44
+ const pendingMax = pending.length ? Math.max(...pending) : null;
45
+ let cursor = 0;
46
+ if (closedMax !== null && pendingMax !== null) {
47
+ cursor = Math.min(closedMax, pendingMax);
48
+ } else if (pendingMax !== null) {
49
+ cursor = pendingMax;
50
+ } else if (closedMax !== null) {
51
+ cursor = closedMax;
52
+ }
53
+ return cursor;
54
+ }
55
+
56
+ /**
57
+ * In-memory checkpoint state port for tests and local runs.
58
+ *
59
+ * @param {object} store - shared mutable store with segments and metrics arrays
60
+ * @returns {object} checkpoints port matching checkpointStatePg shape
61
+ */
62
+ export default function checkpointStateMemory(store) {
63
+ return {
64
+ replayCursor(machineId) {
65
+ return replayCursorFor(store, machineId);
66
+ },
67
+ pendingSegments() {
68
+ return store.segments.filter((row) => {
69
+ return Number(row.duration) === 0;
70
+ }).map((row) => {
71
+ return {
72
+ machine: row.machine,
73
+ name: row.name,
74
+ start: new Date(row.start_time).getTime() / 1000,
75
+ end: new Date(row.end_time).getTime() / 1000
76
+ };
77
+ });
78
+ },
79
+ readings(topics, fromEpoch) {
80
+ const fromMs = Number(fromEpoch) * 1000;
81
+ return store.metrics.filter((row) => {
82
+ const tsMs = new Date(row.ts).getTime();
83
+ return topics.includes(row.topic) && tsMs > fromMs;
84
+ }).sort((a, b) => {
85
+ return new Date(a.ts) - new Date(b.ts);
86
+ }).map((row) => {
87
+ return {
88
+ topic: row.topic,
89
+ timestamp: new Date(row.ts).getTime() / 1000,
90
+ value: Number(row.value)
91
+ };
92
+ });
93
+ },
94
+ segment(machineId, startEpoch) {
95
+ return segmentAt(store, machineId, startEpoch);
96
+ }
97
+ };
98
+ }
@@ -0,0 +1,69 @@
1
+ function bucketKey(tsMs, originMs, stepMs) {
2
+ const slot = Math.floor((tsMs - originMs) / stepMs);
3
+ return originMs + slot * stepMs;
4
+ }
5
+
6
+ function rowsForRange(store, topic, startMs, endMs, stepMs) {
7
+ const hits = store.metrics.filter((row) => {
8
+ const tsMs = new Date(row.ts).getTime();
9
+ return row.topic === topic && tsMs >= startMs && tsMs <= endMs;
10
+ }).sort((a, b) => {
11
+ return new Date(a.ts) - new Date(b.ts);
12
+ });
13
+ const byBucket = new Map();
14
+ hits.forEach((row) => {
15
+ const tsMs = new Date(row.ts).getTime();
16
+ const key = bucketKey(tsMs, startMs, stepMs);
17
+ byBucket.set(key, row);
18
+ });
19
+ return Array.from(byBucket.entries()).sort((a, b) => {
20
+ return a[0] - b[0];
21
+ }).map((entry) => {
22
+ return { ts: new Date(entry[0]), value: entry[1].value };
23
+ });
24
+ }
25
+
26
+ /**
27
+ * In-memory metrics state port for tests and local runs.
28
+ *
29
+ * @param {object} store - shared mutable store with metrics array
30
+ * @returns {object} metrics port matching metricsStatePg shape
31
+ */
32
+ export default function metricsStateMemory(store) {
33
+ return {
34
+ latestForTopic(topic) {
35
+ const hits = store.metrics.filter((row) => {
36
+ return row.topic === topic;
37
+ }).sort((a, b) => {
38
+ return new Date(b.ts) - new Date(a.ts);
39
+ });
40
+ return hits[0] ?? null;
41
+ },
42
+ rangeForTopic(topic, startIso, endIso, stepMs) {
43
+ const start = new Date(startIso).getTime();
44
+ const end = new Date(endIso).getTime();
45
+ const stepSec = Math.max(1, Math.floor(stepMs / 1000));
46
+ return rowsForRange(store, topic, start, end, stepSec * 1000);
47
+ },
48
+ pollTopic(topic, afterIso, untilIso) {
49
+ const after = new Date(afterIso).getTime();
50
+ const until = new Date(untilIso).getTime();
51
+ return store.metrics.filter((row) => {
52
+ const tsMs = new Date(row.ts).getTime();
53
+ return row.topic === topic && tsMs > after && tsMs <= until;
54
+ }).sort((a, b) => {
55
+ return new Date(a.ts) - new Date(b.ts);
56
+ }).slice(0, 100).map((row) => {
57
+ return { ts: row.ts, value: row.value };
58
+ });
59
+ },
60
+ insertRows(items) {
61
+ let i = 0;
62
+ while (i < items.length) {
63
+ const row = items[i];
64
+ store.metrics.push({ topic: row.topic, ts: new Date(row.ts), value: row.value });
65
+ i += 1;
66
+ }
67
+ }
68
+ };
69
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Metrics port used when SINK_DB_PROFILE is central (ClickHouse owns metrics).
3
+ */
4
+ export default function metricsStateDisabled() {
5
+ return {
6
+ latestForTopic() {
7
+ return Promise.resolve(null);
8
+ },
9
+ rangeForTopic() {
10
+ return Promise.resolve([]);
11
+ },
12
+ pollTopic() {
13
+ return Promise.resolve([]);
14
+ },
15
+ insertRows() {
16
+ return Promise.reject(new Error('metrics storage is disabled on central profile'));
17
+ }
18
+ };
19
+ }
@@ -0,0 +1,87 @@
1
+ function sameKey(left, right) {
2
+ return left.key === right.key;
3
+ }
4
+
5
+ function findRow(store, key) {
6
+ return store.operations.find((row) => {
7
+ return sameKey(row, { key });
8
+ });
9
+ }
10
+
11
+ function findScoped(store, machineId, key) {
12
+ return store.operations.find((row) => {
13
+ return row.machine === machineId && row.key === key;
14
+ });
15
+ }
16
+
17
+ function missing(machineId, key) {
18
+ return new Error(`operation '${key}' not found for machine '${machineId}'`);
19
+ }
20
+
21
+ function filterList(store, machineId, kind, range) {
22
+ const from = range.from ?? null;
23
+ const to = range.to ?? null;
24
+ return store.operations.filter((row) => {
25
+ if (row.machine !== machineId || row.kind !== kind) {
26
+ return false;
27
+ }
28
+ if (from && new Date(row.occurred_at).getTime() < new Date(from).getTime()) {
29
+ return false;
30
+ }
31
+ if (to && new Date(row.occurred_at).getTime() > new Date(to).getTime()) {
32
+ return false;
33
+ }
34
+ return true;
35
+ }).sort((left, right) => {
36
+ return new Date(left.occurred_at) - new Date(right.occurred_at);
37
+ });
38
+ }
39
+
40
+ /**
41
+ * In-memory operations state port for tests and local runs.
42
+ *
43
+ * @param {object} store - shared mutable store with operations array
44
+ * @returns {object} operations port matching operationStatePg shape
45
+ */
46
+ export default function operationStateMemory(store) {
47
+ if (!store.operations) {
48
+ store.operations = [];
49
+ }
50
+ return {
51
+ upsert(item) {
52
+ const row = findRow(store, item.key);
53
+ if (row) {
54
+ row.machine = item.machine;
55
+ row.occurred_at = item.occurred_at;
56
+ row.kind = item.kind;
57
+ row.payload = item.payload;
58
+ if (item.source_updated_at) {
59
+ row.source_updated_at = item.source_updated_at;
60
+ }
61
+ return Promise.resolve({ created: false });
62
+ }
63
+ store.operations.push({ ...item });
64
+ return Promise.resolve({ created: true });
65
+ },
66
+ get(machineId, key) {
67
+ const row = findScoped(store, machineId, key);
68
+ if (!row) {
69
+ return Promise.reject(missing(machineId, key));
70
+ }
71
+ return Promise.resolve(row);
72
+ },
73
+ remove(machineId, key) {
74
+ const index = store.operations.findIndex((row) => {
75
+ return row.machine === machineId && row.key === key;
76
+ });
77
+ if (index < 0) {
78
+ return Promise.reject(missing(machineId, key));
79
+ }
80
+ const [row] = store.operations.splice(index, 1);
81
+ return Promise.resolve(row);
82
+ },
83
+ listForMachine(machineId, kind, range) {
84
+ return Promise.resolve(filterList(store, machineId, kind, range));
85
+ }
86
+ };
87
+ }
@@ -0,0 +1,83 @@
1
+ function sameInstant(a, b) {
2
+ return new Date(a).getTime() === new Date(b).getTime();
3
+ }
4
+
5
+ function findRow(store, machineId, start) {
6
+ return store.segments.find((row) => {
7
+ return row.machine === machineId && sameInstant(row.start_time, start);
8
+ });
9
+ }
10
+
11
+ function parseListFilters(range) {
12
+ const from = range.from ?? null;
13
+ const to = range.to ?? null;
14
+ return { from, to };
15
+ }
16
+
17
+ function filterList(store, machineId, range) {
18
+ const { from, to } = parseListFilters(range);
19
+ return store.segments.filter((row) => {
20
+ if (row.machine !== machineId) {
21
+ return false;
22
+ }
23
+ if (from && new Date(row.end_time).getTime() < new Date(from).getTime() && row.duration !== 0) {
24
+ return false;
25
+ }
26
+ if (to && new Date(row.start_time).getTime() > new Date(to).getTime()) {
27
+ return false;
28
+ }
29
+ return true;
30
+ }).sort((a, b) => {
31
+ return new Date(a.start_time) - new Date(b.start_time);
32
+ });
33
+ }
34
+
35
+ /**
36
+ * In-memory segment state port for tests and local runs.
37
+ *
38
+ * @param {object} store - shared mutable store with segments array
39
+ * @returns {object} segments port matching segmentStatePg shape
40
+ */
41
+ export default function segmentStateMemory(store) {
42
+ return {
43
+ listForMachine(machineId, range) {
44
+ return filterList(store, machineId, range);
45
+ },
46
+ rowAt(machineId, start) {
47
+ const row = findRow(store, machineId, start);
48
+ return row ?? null;
49
+ },
50
+ pendingRequestsForMachine(machineId) {
51
+ return store.segments.filter((row) => {
52
+ return row.machine === machineId && row.resolved === false;
53
+ }).sort((a, b) => {
54
+ return new Date(a.start_time) - new Date(b.start_time);
55
+ }).map((row) => {
56
+ return {
57
+ id: row.start_time,
58
+ name: row.name,
59
+ start_time: row.start_time,
60
+ end_time: row.end_time,
61
+ duration: row.duration,
62
+ options: row.options
63
+ };
64
+ });
65
+ },
66
+ retag(machineId, start, tagsJson, propertiesJson) {
67
+ const row = findRow(store, machineId, start);
68
+ if (row) {
69
+ row.tags = tagsJson;
70
+ row.properties = propertiesJson;
71
+ }
72
+ },
73
+ resolveRequest(machineId, startKey, tagsJson, propertiesJson) {
74
+ const row = findRow(store, machineId, startKey);
75
+ if (row) {
76
+ row.tags = tagsJson;
77
+ row.properties = propertiesJson;
78
+ row.resolved = true;
79
+ row.consumed = false;
80
+ }
81
+ }
82
+ };
83
+ }
@@ -0,0 +1,56 @@
1
+ import timeline from '../../../domain/timeline/timeline.js';
2
+
3
+ /**
4
+ * In-memory segment store with timeline read port.
5
+ *
6
+ * @returns {object} items, pending arrays and read port
7
+ *
8
+ * @example
9
+ * const store = memoryTimelineStore();
10
+ * store.items.push({ name: 'on', start_time: new Date(), end_time: new Date(), duration: 0 });
11
+ */
12
+ export default function memoryTimelineStore() {
13
+ const items = [];
14
+ const pending = [];
15
+ return {
16
+ items,
17
+ pending,
18
+ read: {
19
+ list(range) {
20
+ if (!range) {
21
+ return items.slice();
22
+ }
23
+ return items.filter((item) => {
24
+ if (range.from && item.end_time < new Date(range.from)) {
25
+ return false;
26
+ }
27
+ if (range.to && item.start_time > new Date(range.to)) {
28
+ return false;
29
+ }
30
+ return true;
31
+ });
32
+ },
33
+ rowAt(start) {
34
+ return items.find((item) => {
35
+ return item.start_time.getTime() === start.getTime();
36
+ }) || null;
37
+ },
38
+ pending() {
39
+ return pending.filter((item) => {
40
+ return !item.resolved;
41
+ });
42
+ }
43
+ }
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Builds in-memory timeline read port wired to a pubsub bus.
49
+ *
50
+ * @param {object} store - memoryTimelineStore instance
51
+ * @param {object} bus - pubsub instance
52
+ * @returns {object} timeline with list, rowAt, pending, stream
53
+ */
54
+ export function memoryTimelineRead(store, bus) {
55
+ return timeline(store.read, bus);
56
+ }