@yarkivaev/scada 2.3.85 → 2.3.87

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.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Reads epoch milliseconds from a number, Date, or parseable string.
3
+ *
4
+ * @param {Date|number|string} value - instant
5
+ * @returns {number} epoch milliseconds, or NaN
6
+ *
7
+ * @example
8
+ * epoch('2024-11-02T03:04:05.137Z');
9
+ */
10
+ function epoch(value) {
11
+ if (typeof value === 'number') {
12
+ return value;
13
+ }
14
+ if (value instanceof Date) {
15
+ return value.getTime();
16
+ }
17
+ return Date.parse(String(value));
18
+ }
19
+
20
+ /**
21
+ * Converts an instant and rejects values that are not a time.
22
+ *
23
+ * @param {Date|number|string} value - instant
24
+ * @returns {number} epoch milliseconds
25
+ *
26
+ * @example
27
+ * stamp('2024-11-02T03:04:05.137Z');
28
+ */
29
+ function stamp(value) {
30
+ const at = epoch(value);
31
+ if (Number.isNaN(at)) {
32
+ throw new Error(`Coverage instant is not a time: ${value}`);
33
+ }
34
+ return at;
35
+ }
36
+
37
+ /**
38
+ * Reads a sample instant from a raw value or a ts field.
39
+ *
40
+ * @param {Date|number|string|object} sample - sample
41
+ * @returns {Date|number|string} instant
42
+ *
43
+ * @example
44
+ * readSample({ ts: '2024-11-02T03:04:05.137Z' });
45
+ */
46
+ function readSample(sample) {
47
+ if (sample && typeof sample === 'object' && Object.hasOwn(sample, 'ts')) {
48
+ return sample.ts;
49
+ }
50
+ return sample;
51
+ }
52
+
53
+ /**
54
+ * Keeps samples inside the cursor-covered part of the window, in order.
55
+ *
56
+ * @param {Array} samples - raw samples
57
+ * @param {number} start - window start epoch
58
+ * @param {number} stop - covered end epoch
59
+ * @returns {Array<number>} sorted epochs
60
+ *
61
+ * @example
62
+ * clip(['2024-11-02T03:04:05.137Z'], 0, 1);
63
+ */
64
+ function clip(samples, start, stop) {
65
+ const picked = [];
66
+ for (const sample of samples) {
67
+ const at = stamp(readSample(sample));
68
+ if (at >= start && at <= stop) {
69
+ picked.push(at);
70
+ }
71
+ }
72
+ picked.sort((left, right) => {
73
+ return left - right;
74
+ });
75
+ return picked;
76
+ }
77
+
78
+ /**
79
+ * Builds one span per consecutive bound.
80
+ *
81
+ * @param {number} start - covered start
82
+ * @param {number} stop - covered end
83
+ * @param {Array<number>} points - sample epochs
84
+ * @param {number} silence - silence milliseconds
85
+ * @returns {Array<object>} unmerged spans
86
+ *
87
+ * @example
88
+ * gaps(0, 10, [0, 10], 30);
89
+ */
90
+ function gaps(start, stop, points, silence) {
91
+ const bounds = [start, ...points, stop];
92
+ const spans = [];
93
+ for (let index = 0; index < bounds.length - 1; index += 1) {
94
+ const left = bounds[index];
95
+ const right = bounds[index + 1];
96
+ const status = right - left > silence ? 'absent' : 'present';
97
+ spans.push({ from: left, status, to: right });
98
+ }
99
+ return spans;
100
+ }
101
+
102
+ /**
103
+ * Drops empty spans and joins neighbors that share a status.
104
+ *
105
+ * @param {Array<object>} spans - raw spans
106
+ * @returns {Array<object>} merged spans
107
+ *
108
+ * @example
109
+ * merge([{ from: 0, status: 'present', to: 10 }]);
110
+ */
111
+ function merge(spans) {
112
+ const open = spans.filter((span) => {
113
+ return span.to > span.from;
114
+ });
115
+ const kept = [];
116
+ for (const span of open) {
117
+ const last = kept[kept.length - 1];
118
+ if (last && last.status === span.status && last.to === span.from) {
119
+ kept[kept.length - 1] = { from: last.from, status: last.status, to: span.to };
120
+ } else {
121
+ kept.push({ from: span.from, status: span.status, to: span.to });
122
+ }
123
+ }
124
+ return kept;
125
+ }
126
+
127
+ /**
128
+ * Classifies the cursor-covered interval.
129
+ *
130
+ * @param {number} start - window start
131
+ * @param {number} stop - covered end
132
+ * @param {Array<number>} points - samples inside the covered interval
133
+ * @param {number} silence - silence milliseconds
134
+ * @returns {Array<object>} present and absent spans
135
+ *
136
+ * @example
137
+ * paint(0, 10, [], 30);
138
+ */
139
+ function paint(start, stop, points, silence) {
140
+ if (stop <= start) {
141
+ return [];
142
+ }
143
+ if (points.length === 0) {
144
+ return [{ from: start, status: 'absent', to: stop }];
145
+ }
146
+ return merge(gaps(start, stop, points, silence));
147
+ }
148
+
149
+ /**
150
+ * Renders spans as ISO-8601 instants.
151
+ *
152
+ * @param {Array<object>} spans - epoch spans
153
+ * @returns {Array<object>} ISO spans
154
+ *
155
+ * @example
156
+ * iso([{ from: 0, status: 'unknown', to: 1 }]);
157
+ */
158
+ function iso(spans) {
159
+ return spans.map((span) => {
160
+ return {
161
+ from: new Date(span.from).toISOString(),
162
+ status: span.status,
163
+ to: new Date(span.to).toISOString()
164
+ };
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Requires a non-negative silence gap.
170
+ *
171
+ * @param {number} silenceMs - silence milliseconds
172
+ * @returns {number} silence milliseconds
173
+ *
174
+ * @example
175
+ * silenceOf(60000);
176
+ */
177
+ function silenceOf(silenceMs) {
178
+ if (typeof silenceMs === 'number' && silenceMs >= 0) {
179
+ return silenceMs;
180
+ }
181
+ throw new Error(`Coverage silence is missing: ${silenceMs}`);
182
+ }
183
+
184
+ /**
185
+ * Requires an array of samples.
186
+ *
187
+ * @param {Array} samples - raw samples
188
+ * @returns {Array} samples
189
+ *
190
+ * @example
191
+ * samplesOf([]);
192
+ */
193
+ function samplesOf(samples) {
194
+ if (Array.isArray(samples)) {
195
+ return samples;
196
+ }
197
+ throw new Error('Coverage samples are missing');
198
+ }
199
+
200
+ /**
201
+ * Classifies a metric window as present, absent, or unknown.
202
+ *
203
+ * Present means consecutive samples stay within the silence gap. Absent means
204
+ * a hole is longer than that gap and the ingest cursor has already passed it.
205
+ * Unknown is the part of the window after the cursor. An empty window that the
206
+ * cursor already covered is absent.
207
+ *
208
+ * @param {Array} samples - instants, or objects with ts
209
+ * @param {Date|number|string} from - window start
210
+ * @param {Date|number|string} to - window end
211
+ * @param {number} silenceMs - maximum present gap in milliseconds
212
+ * @param {Date|number|string} cursor - latest ingested instant
213
+ * @returns {Array<{from: string, to: string, status: string}>} ISO spans
214
+ *
215
+ * @example
216
+ * coverageSpans(['2024-11-02T03:04:05.137Z'], '2024-11-02T03:04:05.137Z',
217
+ * '2024-11-02T03:05:05.137Z', 60000, '2024-11-02T03:05:05.137Z');
218
+ */
219
+ export default function coverageSpans(samples, from, to, silenceMs, cursor) {
220
+ const end = stamp(to);
221
+ const mark = stamp(cursor);
222
+ const silence = silenceOf(silenceMs);
223
+ const start = stamp(from);
224
+ const stop = mark < end ? Math.max(mark, start) : end;
225
+ const points = clip(samplesOf(samples), start, stop);
226
+ const known = paint(start, stop, points, silence);
227
+ const tail = stop < end ? [{ from: stop, status: 'unknown', to: end }] : [];
228
+ return iso(merge(known.concat(tail)));
229
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Requires SQL text.
3
+ *
4
+ * @param {string} sql - statement
5
+ * @param {string} engine - clickhouse or postgres
6
+ * @returns {string} sql
7
+ *
8
+ * @example
9
+ * textOf('SELECT 1', 'postgres');
10
+ */
11
+ function textOf(sql, engine) {
12
+ if (typeof sql === 'string') {
13
+ return sql;
14
+ }
15
+ throw new Error(`SQL text is missing for ${engine}`);
16
+ }
17
+
18
+ /**
19
+ * Strips comments and masks string literals so keyword checks see code.
20
+ *
21
+ * @param {string} sql - raw statement
22
+ * @returns {string} masked statement
23
+ *
24
+ * @example
25
+ * mask("SELECT 'drop'");
26
+ */
27
+ function mask(sql) {
28
+ const comments = sql.replace(/\/\*[\s\S]*?\*\//gu, ' ');
29
+ const lines = comments.replace(/--[^\n]*/gu, ' ');
30
+ return lines.replace(/'(?:''|[^'])*'/gu, '?');
31
+ }
32
+
33
+ /**
34
+ * Keeps a single statement and rejects empties and batches.
35
+ *
36
+ * @param {string} masked - masked SQL
37
+ * @param {string} engine - engine name
38
+ * @returns {string} trimmed statement
39
+ *
40
+ * @example
41
+ * one('SELECT 1', 'postgres');
42
+ */
43
+ function one(masked, engine) {
44
+ const trimmed = masked.trim().replace(/;+\s*$/u, '').trim();
45
+ if (trimmed.includes(';')) {
46
+ throw new Error(`SQL has more than one statement for ${engine}`);
47
+ }
48
+ if (trimmed.length === 0) {
49
+ throw new Error(`SQL is empty for ${engine}`);
50
+ }
51
+ return trimmed;
52
+ }
53
+
54
+ /**
55
+ * Shortens SQL for an error without adding a sentence period.
56
+ *
57
+ * @param {string} body - statement
58
+ * @returns {string} single-line preview
59
+ *
60
+ * @example
61
+ * preview('SELECT 1');
62
+ */
63
+ function preview(body) {
64
+ const flat = body.replace(/\s+/gu, ' ').trim();
65
+ if (flat.length > 120) {
66
+ return flat.slice(0, 120);
67
+ }
68
+ return flat;
69
+ }
70
+
71
+ /**
72
+ * Allows only SELECT and WITH.
73
+ *
74
+ * @param {string} body - one statement
75
+ * @param {string} engine - engine name
76
+ * @returns {void}
77
+ *
78
+ * @example
79
+ * assertRead('SELECT 1', 'postgres');
80
+ */
81
+ function assertRead(body, engine) {
82
+ if (/^(?:select|with)\b/iu.test(body)) {
83
+ return;
84
+ }
85
+ throw new Error(`SQL is not a read for ${engine}: ${preview(body)}`);
86
+ }
87
+
88
+ /**
89
+ * Rejects write and admin keywords.
90
+ *
91
+ * @param {string} body - masked statement
92
+ * @param {string} engine - engine name
93
+ * @returns {void}
94
+ *
95
+ * @example
96
+ * assertWords('SELECT 1', 'postgres');
97
+ */
98
+ function assertWords(body, engine) {
99
+ const found = /\b(?<word>alter|copy|delete|drop|grant|insert|system|truncate|update)\b/iu.exec(body);
100
+ if (found) {
101
+ throw new Error(`SQL contains ${found.groups.word.toLowerCase()} for ${engine}`);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Rejects the operator credential column.
107
+ *
108
+ * @param {string} body - masked statement
109
+ * @param {string} engine - engine name
110
+ * @returns {void}
111
+ *
112
+ * @example
113
+ * assertUid('SELECT name FROM operators', 'postgres');
114
+ */
115
+ function assertUid(body, engine) {
116
+ if (/\bcard_uid\b/iu.test(body)) {
117
+ throw new Error(`SQL references card_uid for ${engine}`);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Requires a ts comparison on ClickHouse.
123
+ *
124
+ * @param {string} body - masked statement
125
+ * @param {string} engine - engine name
126
+ * @returns {void}
127
+ *
128
+ * @example
129
+ * assertTs('SELECT value FROM metrics WHERE ts >= 1', 'clickhouse');
130
+ */
131
+ function assertTs(body, engine) {
132
+ if (engine !== 'clickhouse') {
133
+ return;
134
+ }
135
+ if (/\bts\s*(?:>=|<=|<>|!=|>|<|=|between\b|in\b)/iu.test(body)) {
136
+ return;
137
+ }
138
+ throw new Error(`ClickHouse SQL lacks a ts predicate: ${preview(body)}`);
139
+ }
140
+
141
+ /**
142
+ * Trims one trailing semicolon from the original text.
143
+ *
144
+ * @param {string} sql - original statement
145
+ * @returns {string} statement to execute
146
+ *
147
+ * @example
148
+ * executable('SELECT 1;');
149
+ */
150
+ function executable(sql) {
151
+ return sql.trim().replace(/;+\s*$/u, '').trim();
152
+ }
153
+
154
+ /**
155
+ * Rejects analytics SQL that is not one read-only statement.
156
+ *
157
+ * SELECT and WITH are allowed. Writes, card_uid, and extra statements are
158
+ * rejected before a driver sees them. ClickHouse also needs a ts predicate.
159
+ *
160
+ * @param {string} sql - one statement
161
+ * @param {string} engine - clickhouse or postgres
162
+ * @returns {string} trimmed statement safe to execute
163
+ *
164
+ * @example
165
+ * sqlGuard("SELECT value FROM metrics WHERE ts >= '2024-11-02'", 'clickhouse');
166
+ */
167
+ export default function sqlGuard(sql, engine) {
168
+ const text = textOf(sql, engine);
169
+ const body = one(mask(text), engine);
170
+ assertRead(body, engine);
171
+ assertWords(body, engine);
172
+ assertUid(body, engine);
173
+ assertTs(body, engine);
174
+ return executable(text);
175
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Collapses SQL literals so journals group by statement shape.
3
+ *
4
+ * Quoted strings and numbers become ?. Whitespace becomes a single space.
5
+ *
6
+ * @param {string} sql - original statement
7
+ * @returns {string} shape
8
+ *
9
+ * @example
10
+ * sqlShape("SELECT value FROM metrics WHERE topic = 'α' AND ts >= 1");
11
+ */
12
+ export default function sqlShape(sql) {
13
+ const quotes = String(sql).replace(/'(?:''|[^'])*'/gu, '?');
14
+ const numbers = quotes.replace(/\b\d+(?:\.\d+)?\b/gu, '?');
15
+ return numbers.replace(/\s+/gu, ' ').trim();
16
+ }
@@ -1,12 +1,41 @@
1
1
  /**
2
- * PostgreSQL alert hydration port for supervisor-sink and plant STOMP alerts.
2
+ * Builds a filtered alerts SELECT.
3
+ *
4
+ * @param {object} filters - optional machine, from, to
5
+ * @returns {{sql: string, prm: Array}} query
6
+ *
7
+ * @example
8
+ * listSql({ machine: 'm1', from: start, to: end });
9
+ */
10
+ function listSql(filters) {
11
+ const prm = [];
12
+ const clauses = [];
13
+ const scope = filters || {};
14
+ if (scope.machine) {
15
+ prm.push(scope.machine);
16
+ clauses.push(`machine = $${prm.length}`);
17
+ }
18
+ if (scope.from) {
19
+ prm.push(scope.from);
20
+ clauses.push(`"timestamp" >= $${prm.length}`);
21
+ }
22
+ if (scope.to) {
23
+ prm.push(scope.to);
24
+ clauses.push(`"timestamp" <= $${prm.length}`);
25
+ }
26
+ const where = clauses.length === 0 ? '' : ` WHERE ${clauses.join(' AND ')}`;
27
+ return { prm, sql: `SELECT * FROM alerts${where} ORDER BY timestamp` };
28
+ }
29
+
30
+ /**
31
+ * PostgreSQL alert hydration and range-read port.
3
32
  *
4
33
  * @param {object} pool - pg pool
5
- * @returns {object} alerts port with put and listUnacknowledged
34
+ * @returns {object} alerts port with put, list, and listUnacknowledged
6
35
  *
7
36
  * @example
8
37
  * const store = alertsStatePg(pool);
9
- * const rows = await store.listUnacknowledged({ machine: 'm1' });
38
+ * const rows = await store.list({ machine: 'm1', from, to });
10
39
  */
11
40
  export default function alertsStatePg(pool) {
12
41
  return {
@@ -25,6 +54,11 @@ export default function alertsStatePg(pool) {
25
54
  );
26
55
  return inserted.rows[0];
27
56
  },
57
+ async list(filters) {
58
+ const query = listSql(filters);
59
+ const result = await pool.query(query.sql, query.prm);
60
+ return result.rows;
61
+ },
28
62
  async listUnacknowledged(filters) {
29
63
  let sql = 'SELECT * FROM alerts WHERE acknowledged = FALSE';
30
64
  const prm = [];