@yarkivaev/scada 2.3.85 → 2.3.86

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,182 @@
1
+ /**
2
+ * Reads a non-empty path.
3
+ *
4
+ * @param {object} node - candidate node
5
+ * @returns {string} path
6
+ *
7
+ * @example
8
+ * pathOf({ path: 'site/unit' });
9
+ */
10
+ function pathOf(node) {
11
+ if (typeof node.path === 'string' && node.path.length > 0) {
12
+ return node.path;
13
+ }
14
+ throw new Error('Namespace path is empty');
15
+ }
16
+
17
+ /**
18
+ * Requires a non-empty opaque kind.
19
+ *
20
+ * @param {object} node - candidate node
21
+ * @returns {string} kind
22
+ *
23
+ * @example
24
+ * kindOf({ kind: 'metric', path: 'site/metric' });
25
+ */
26
+ function kindOf(node) {
27
+ if (typeof node.kind === 'string' && node.kind.length > 0) {
28
+ return node.kind;
29
+ }
30
+ throw new Error(`Namespace kind is missing for ${pathOf(node)}`);
31
+ }
32
+
33
+ /**
34
+ * Requires a non-negative silence threshold in seconds.
35
+ *
36
+ * @param {object} node - candidate node
37
+ * @returns {number} silence seconds
38
+ *
39
+ * @example
40
+ * silenceOf({ path: 'site', silenceSeconds: 60 });
41
+ */
42
+ function silenceOf(node) {
43
+ const value = node.silenceSeconds;
44
+ if (typeof value === 'number' && value >= 0) {
45
+ return value;
46
+ }
47
+ throw new Error(`Namespace silence is missing for ${pathOf(node)}`);
48
+ }
49
+
50
+ /**
51
+ * Uses an explicit title or the last path segment.
52
+ *
53
+ * @param {object} node - candidate node
54
+ * @param {string} path - unique path
55
+ * @returns {string} display title
56
+ *
57
+ * @example
58
+ * titled({ title: 'Unit' }, 'site/unit');
59
+ */
60
+ function titled(node, path) {
61
+ if (typeof node.title === 'string' && node.title.length > 0) {
62
+ return node.title;
63
+ }
64
+ const parts = path.split('/');
65
+ return parts[parts.length - 1];
66
+ }
67
+
68
+ /**
69
+ * Copies one node into a frozen record.
70
+ *
71
+ * @param {object} node - candidate node
72
+ * @param {string} path - unique path
73
+ * @returns {object} frozen node
74
+ *
75
+ * @example
76
+ * freezeNode({ kind: 'site', path: 'site', silenceSeconds: 30 }, 'site');
77
+ */
78
+ function freezeNode(node, path) {
79
+ const frozen = {
80
+ kind: kindOf(node),
81
+ path,
82
+ silenceSeconds: silenceOf(node),
83
+ title: titled(node, path)
84
+ };
85
+ if (typeof node.source === 'string') {
86
+ frozen.source = node.source;
87
+ }
88
+ if (typeof node.unit === 'string') {
89
+ frozen.unit = node.unit;
90
+ }
91
+ return Object.freeze(frozen);
92
+ }
93
+
94
+ /**
95
+ * Builds a path map and rejects duplicates.
96
+ *
97
+ * @param {Array<object>} nodes - candidate nodes
98
+ * @returns {{ list: Array<object>, map: Map<string, object> }} index
99
+ *
100
+ * @example
101
+ * indexNodes([{ kind: 'site', path: 'site', silenceSeconds: 30 }]);
102
+ */
103
+ function indexNodes(nodes) {
104
+ const map = new Map();
105
+ for (const node of nodes) {
106
+ const path = pathOf(node);
107
+ if (map.has(path)) {
108
+ throw new Error(`Namespace path is duplicated: ${path}`);
109
+ }
110
+ map.set(path, freezeNode(node, path));
111
+ }
112
+ return { list: [...map.values()], map };
113
+ }
114
+
115
+ /**
116
+ * Returns the node for a path or throws when it is absent.
117
+ *
118
+ * @param {Map<string, object>} map - path index
119
+ * @param {string} path - requested path
120
+ * @returns {object} frozen node
121
+ *
122
+ * @example
123
+ * lookup(new Map([['site', { path: 'site' }]]), 'site');
124
+ */
125
+ function lookup(map, path) {
126
+ const node = map.get(path);
127
+ if (node) {
128
+ return node;
129
+ }
130
+ throw new Error(`Namespace path is missing: ${path}`);
131
+ }
132
+
133
+ /**
134
+ * Requires an array of nodes.
135
+ *
136
+ * @param {Array<object>} nodes - candidate nodes
137
+ * @returns {Array<object>} the same array when it is an array
138
+ *
139
+ * @example
140
+ * nodesOf([{ kind: 'site', path: 'site', silenceSeconds: 30 }]);
141
+ */
142
+ function nodesOf(nodes) {
143
+ if (Array.isArray(nodes)) {
144
+ return nodes;
145
+ }
146
+ throw new Error('Namespace nodes are missing');
147
+ }
148
+
149
+ /**
150
+ * Indexes plant nodes by a unique path for analytics reads.
151
+ *
152
+ * A node is an opaque kind, a silence threshold in seconds, and an optional
153
+ * source topic. Paths are the address. Gateway topics stay on the node and
154
+ * are not required.
155
+ *
156
+ * @param {Array<object>} nodes - path, kind, silenceSeconds, optional source, title, unit
157
+ * @returns {object} list, resolve, and below
158
+ *
159
+ * @example
160
+ * const tree = namespaceTree([
161
+ * { path: 'site/shop/unit', kind: 'unit', silenceSeconds: 60 }
162
+ * ]);
163
+ * tree.resolve('site/shop/unit').kind;
164
+ */
165
+ export default function namespaceTree(nodes) {
166
+ const index = indexNodes(nodesOf(nodes));
167
+ return Object.freeze({
168
+ below(path) {
169
+ const node = lookup(index.map, path);
170
+ const prefix = `${node.path}/`;
171
+ return index.list.filter((item) => {
172
+ return item.path.startsWith(prefix);
173
+ });
174
+ },
175
+ list() {
176
+ return index.list.slice();
177
+ },
178
+ resolve(path) {
179
+ return lookup(index.map, path);
180
+ }
181
+ });
182
+ }
@@ -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
+ }