@yarkivaev/scada 2.3.84 → 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.
- package/README.md +20 -0
- package/index.js +4 -0
- package/package.json +4 -2
- package/src/application/mcp/analyticsMcp.js +376 -0
- package/src/application/mcp/mcpCoverage.js +95 -0
- package/src/application/mcp/mcpJournal.js +102 -0
- package/src/application/mcp/mcpMetrics.js +69 -0
- package/src/application/mcp/mcpObserve.js +168 -0
- package/src/application/mcp/mcpQuery.js +80 -0
- package/src/application/mcp/mcpReads.js +221 -0
- package/src/application/mcp/mcpResources.js +106 -0
- package/src/application/mcp/mcpText.js +106 -0
- package/src/domain/namespace/namespaceTree.js +182 -0
- package/src/domain/span/coverageSpans.js +229 -0
- package/src/domain/sql/sqlGuard.js +175 -0
- package/src/domain/sql/sqlShape.js +16 -0
- package/src/infrastructure/messaging/stomp/stompTimelineSegments.js +14 -2
|
@@ -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
|
+
}
|
|
@@ -42,14 +42,24 @@ function segmentRow(parsed, startTime, endTime) {
|
|
|
42
42
|
return row;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function emitPiece(bus, piece) {
|
|
46
|
+
const times = timestamps(piece);
|
|
47
|
+
bus.emit({ type: 'created', segment: segmentRow(piece, times.startTime, times.endTime) });
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
function consume(raw, buses) {
|
|
46
51
|
const parsed = JSON.parse(raw.payload);
|
|
47
52
|
const bus = buses[parsed.machine];
|
|
48
53
|
if (!bus) {
|
|
49
54
|
return;
|
|
50
55
|
}
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
if (parsed.type === 'split-family' && Array.isArray(parsed.pieces)) {
|
|
57
|
+
parsed.pieces.forEach((piece) => {
|
|
58
|
+
emitPiece(bus, piece);
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
emitPiece(bus, parsed);
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
/**
|
|
@@ -57,6 +67,8 @@ function consume(raw, buses) {
|
|
|
57
67
|
*
|
|
58
68
|
* Subscribes to STOMP segment exchange and emits created events
|
|
59
69
|
* on per-machine timeline pubsub buses for SSE delivery.
|
|
70
|
+
* A split-family envelope emits one event per piece. The envelope
|
|
71
|
+
* itself has no start or end, and must not be read as one segment.
|
|
60
72
|
*
|
|
61
73
|
* @param {function} source - factory(collector) returning { start(), stop() }
|
|
62
74
|
* @param {object} buses - map of machine IDs to pubsub buses with emit()
|