@yarkivaev/scada 2.3.86 → 2.3.88

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 CHANGED
@@ -4,13 +4,16 @@ Generic industrial monitoring domain, state persistence, and HTTP APIs (`@yarkiv
4
4
 
5
5
  This package is plant-agnostic: it defines `plant` → `shop` → `machine` hierarchy, timeline and alerts, sensor read ports, STOMP ingest pipelines, and PostgreSQL/ClickHouse adapters. Plant-specific routes, sensors, HMI strings, and tag taxonomies live in downstream plant packages.
6
6
 
7
- `analyticsMcp` is a stateless Streamable HTTP MCP server. The plant package supplies a `namespaceTree` (path, kind, silence, source topic) and read ports. Tools address nodes by path. `query_postgres` is registered only when a standby query port is supplied. Each call writes one JSON line to the injected journal. `GET /metrics` exposes call counts and in-flight reads.
7
+ `analyticsMcp` is a stateless Streamable HTTP MCP server. The plant package supplies a `namespaceTree` (path, kind, silence, source topic), read ports, and the tag catalog already used by `plantApi({ tagCatalog })`. Tools address nodes by path. `query_postgres` is registered only when a standby query port is supplied. Each SQL engine runs one query at a time. Each call writes one JSON line to the injected journal. `GET /metrics` exposes call counts and in-flight reads.
8
+
9
+ Resources: `plant://topology`, `plant://bindings`, `plant://tags`, `plant://schema/postgres`, `plant://schema/clickhouse`. Column lists live in the schema resources, not here.
8
10
 
9
11
  ```javascript
10
- import { analyticsMcp, namespaceTree } from '@yarkivaev/scada';
12
+ import { analyticsMcp, namespaceTree, tagCatalog } from '@yarkivaev/scada';
11
13
 
12
14
  const mcp = analyticsMcp({
13
15
  tree: namespaceTree(nodes),
16
+ catalog: tagCatalog(),
14
17
  tokens: { secret: 'analyst' },
15
18
  journal(line) { process.stdout.write(line); },
16
19
  ports: {
@@ -21,7 +24,7 @@ const mcp = analyticsMcp({
21
24
  await mcp.listen(0, '127.0.0.1');
22
25
  ```
23
26
 
24
- `ports.queryPostgres` registers `query_postgres`. `extend(server)` may add plant tools. `POST /mcp` requires `Authorization: Bearer`.
27
+ `ports.queryPostgres` registers `query_postgres`. `extend(server)` may add plant tools. `POST /mcp` requires `Authorization: Bearer`. Coverage spans add the overlapping timeline segment name as `phase`. The process journal is stdout only; `activityTracking` still writes Loki HTTP access into `activity.http_access`, not `activity.mcp_calls`.
25
28
 
26
29
  Repository: `https://github.com/yarkivaev/scada`
27
30
 
@@ -132,7 +135,7 @@ Main entry (`import { … } from '@yarkivaev/scada'`):
132
135
  | `stompTimeline`, `stompAlerts`, `userDecisions` | STOMP messaging |
133
136
  | `machineOwners`, `ownerTimeline`, `httpTimeline`, `httpOperations` | Owner-routed timeline and operations write (local or edge HTTP) |
134
137
  | `mqttMetrics`, `modbusMqtt`, `activityTracking`, `amqpMetricsIngest` | Ingest pipelines |
135
- | `analyticsMcp`, `namespaceTree`, `coverageSpans`, `sqlGuard` | Analytics MCP, path index, coverage spans, read-only SQL guard |
138
+ | `analyticsMcp`, `namespaceTree`, `coverageSpans`, `coverageOverlay`, `coverageCursor`, `sqlGuard` | Analytics MCP, path index, coverage spans, timeline-name overlay, checkpoint-or-sample cursor, read-only SQL guard |
136
139
 
137
140
  Subpath exports:
138
141
 
package/index.js CHANGED
@@ -87,6 +87,8 @@ export { default as siteSyncSites } from './src/application/sync/siteSyncSites.j
87
87
  export { default as siteSyncTargets } from './src/application/sync/siteSyncTargets.js';
88
88
  export { default as siteSyncBind } from './src/application/sync/siteSyncBind.js';
89
89
  export { default as namespaceTree } from './src/domain/namespace/namespaceTree.js';
90
+ export { default as coverageOverlay } from './src/domain/span/coverageOverlay.js';
90
91
  export { default as coverageSpans } from './src/domain/span/coverageSpans.js';
92
+ export { default as coverageCursor } from './src/application/mcp/coverageCursor.js';
91
93
  export { default as sqlGuard } from './src/domain/sql/sqlGuard.js';
92
94
  export { default as analyticsMcp } from './src/application/mcp/analyticsMcp.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.3.86",
3
+ "version": "2.3.88",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -2,6 +2,7 @@ import http from 'node:http';
2
2
  import { createRequire } from 'node:module';
3
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import mcpFlight from './mcpFlight.js';
5
6
  import mcpMetrics from './mcpMetrics.js';
6
7
  import observe from './mcpObserve.js';
7
8
  import registerCoverage from './mcpCoverage.js';
@@ -98,20 +99,25 @@ function extendServer(server, bundle) {
98
99
  * Builds one stateless server for a single HTTP request.
99
100
  *
100
101
  * @param {object} bundle - analyticsMcp bundle
101
- * @param {object} metrics - prometheus counters
102
+ * @param {object} runtime - metrics and SQL flight
102
103
  * @param {string} caller - bearer caller name
103
104
  * @returns {object} McpServer
104
105
  *
105
106
  * @example
106
- * openServer(bundle, metrics, 'analyst');
107
+ * openServer(bundle, runtime, 'analyst');
107
108
  */
108
- function openServer(bundle, metrics, caller) {
109
+ function openServer(bundle, runtime, caller) {
109
110
  const server = new McpServer(serverInfo());
110
- observe(server, { caller, journal: bundle.journal, metrics });
111
+ observe(server, {
112
+ caller,
113
+ flight: runtime.flight,
114
+ journal: bundle.journal,
115
+ metrics: runtime.metrics
116
+ });
111
117
  registerReads(server, bundle.ports, bundle.tree);
112
118
  registerCoverage(server, bundle.ports, bundle.tree);
113
119
  registerQueries(server, bundle.ports);
114
- registerResources(server, bundle.tree);
120
+ registerResources(server, bundle.tree, bundle.catalog);
115
121
  extendServer(server, bundle);
116
122
  return server;
117
123
  }
@@ -183,8 +189,8 @@ function releaser(transport, server) {
183
189
  * @example
184
190
  * dispatch(req, res, bundle, metrics, 'analyst');
185
191
  */
186
- async function dispatch(req, res, bundle, metrics, caller) {
187
- const server = openServer(bundle, metrics, caller);
192
+ async function dispatch(req, res, bundle, runtime, caller) {
193
+ const server = openServer(bundle, runtime, caller);
188
194
  const transport = new StreamableHTTPServerTransport({
189
195
  enableJsonResponse: true,
190
196
  sessionIdGenerator: undefined
@@ -214,13 +220,13 @@ async function dispatch(req, res, bundle, metrics, caller) {
214
220
  * @example
215
221
  * postMcp(req, res, bundle, metrics);
216
222
  */
217
- async function postMcp(req, res, bundle, metrics) {
223
+ async function postMcp(req, res, bundle, runtime) {
218
224
  const name = callerName(req, bundle.tokens);
219
225
  if (name.length === 0) {
220
226
  res.writeHead(401).end();
221
227
  return;
222
228
  }
223
- await dispatch(req, res, bundle, metrics, name);
229
+ await dispatch(req, res, bundle, runtime, name);
224
230
  }
225
231
 
226
232
  /**
@@ -233,9 +239,9 @@ async function postMcp(req, res, bundle, metrics) {
233
239
  * @example
234
240
  * writeMetrics(res, metrics);
235
241
  */
236
- function writeMetrics(res, metrics) {
242
+ function writeMetrics(res, runtime) {
237
243
  res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' });
238
- res.end(metrics.text());
244
+ res.end(runtime.metrics.text());
239
245
  }
240
246
 
241
247
  /**
@@ -250,13 +256,13 @@ function writeMetrics(res, metrics) {
250
256
  * @example
251
257
  * route(req, res, bundle, metrics);
252
258
  */
253
- function route(req, res, bundle, metrics) {
259
+ function route(req, res, bundle, runtime) {
254
260
  if (req.method === 'GET' && req.url === '/metrics') {
255
- writeMetrics(res, metrics);
261
+ writeMetrics(res, runtime);
256
262
  return Promise.resolve();
257
263
  }
258
264
  if (req.method === 'POST' && req.url === '/mcp') {
259
- return postMcp(req, res, bundle, metrics);
265
+ return postMcp(req, res, bundle, runtime);
260
266
  }
261
267
  res.writeHead(404).end();
262
268
  return Promise.resolve();
@@ -291,8 +297,8 @@ function fail(res) {
291
297
  * @example
292
298
  * onRequest(req, res, bundle, metrics);
293
299
  */
294
- function onRequest(req, res, bundle, metrics) {
295
- route(req, res, bundle, metrics).catch(() => {
300
+ function onRequest(req, res, bundle, runtime) {
301
+ route(req, res, bundle, runtime).catch(() => {
296
302
  fail(res);
297
303
  });
298
304
  }
@@ -352,18 +358,18 @@ function shut(server) {
352
358
  * is a function. extend(server) may register more tools. POST /mcp speaks MCP.
353
359
  * GET /metrics is Prometheus text.
354
360
  *
355
- * @param {object} bundle - tree, tokens, journal, ports, optional extend
361
+ * @param {object} bundle - tree, tokens, journal, ports, optional catalog and extend
356
362
  * @returns {object} listen and close
357
363
  *
358
364
  * @example
359
- * const mcp = analyticsMcp({ tree, tokens, journal, ports });
365
+ * const mcp = analyticsMcp({ tree, tokens, journal, ports, catalog });
360
366
  * await mcp.listen(0, '127.0.0.1');
361
367
  */
362
368
  export default function analyticsMcp(bundle) {
363
369
  assertBundle(bundle);
364
- const metrics = mcpMetrics();
370
+ const runtime = { flight: mcpFlight(), metrics: mcpMetrics() };
365
371
  const server = http.createServer((req, res) => {
366
- onRequest(req, res, bundle, metrics);
372
+ onRequest(req, res, bundle, runtime);
367
373
  });
368
374
  return Object.freeze({
369
375
  close() {
@@ -0,0 +1,116 @@
1
+ const EMPTY = '1970-01-01T00:00:00.000Z';
2
+
3
+ /**
4
+ * Reads epoch milliseconds from a cursor value.
5
+ *
6
+ * @param {Date|number|string|object} value - instant or { at }
7
+ * @returns {number} epoch milliseconds, or NaN
8
+ *
9
+ * @example
10
+ * instant({ at: '2024-11-02T03:04:05.137Z' });
11
+ */
12
+ function instant(value) {
13
+ if (value && typeof value === 'object' && !(value instanceof Date) && Object.hasOwn(value, 'at')) {
14
+ return instant(value.at);
15
+ }
16
+ if (value instanceof Date) {
17
+ return value.getTime();
18
+ }
19
+ if (typeof value === 'number') {
20
+ return value;
21
+ }
22
+ if (value === undefined || value === null || value === '') {
23
+ return Number.NaN;
24
+ }
25
+ return Date.parse(String(value));
26
+ }
27
+
28
+ /**
29
+ * Formats a covered instant, treating epoch and gaps as empty.
30
+ *
31
+ * @param {Date|number|string|object} value - instant
32
+ * @returns {string} ISO instant, or the empty cursor
33
+ *
34
+ * @example
35
+ * covered('2024-11-02T03:04:05.137Z');
36
+ */
37
+ function covered(value) {
38
+ const at = instant(value);
39
+ if (!Number.isFinite(at) || at <= 0) {
40
+ return EMPTY;
41
+ }
42
+ return new Date(at).toISOString();
43
+ }
44
+
45
+ /**
46
+ * Latest sample instant, or the empty cursor when none exist.
47
+ *
48
+ * @param {Array} samples - instants or objects with ts
49
+ * @returns {string} ISO instant
50
+ *
51
+ * @example
52
+ * latest([{ ts: '2024-11-02T03:04:05.137Z' }]);
53
+ */
54
+ function latest(samples) {
55
+ const rows = Array.isArray(samples) ? samples : [];
56
+ let mark = 0;
57
+ for (const sample of rows) {
58
+ const raw = sample && typeof sample === 'object' && Object.hasOwn(sample, 'ts')
59
+ ? sample.ts
60
+ : sample;
61
+ const at = instant(raw);
62
+ if (Number.isFinite(at) && at > mark) {
63
+ mark = at;
64
+ }
65
+ }
66
+ return mark > 0 ? new Date(mark).toISOString() : EMPTY;
67
+ }
68
+
69
+ /**
70
+ * Reads the newest ingest checkpoint for a machine.
71
+ *
72
+ * @param {object|undefined} pool - pg pool
73
+ * @param {string} machine - machine id
74
+ * @returns {Promise<string>} ISO instant, or the empty cursor
75
+ *
76
+ * @example
77
+ * checkpoint(pool, 'machine1');
78
+ */
79
+ async function checkpoint(pool, machine) {
80
+ if (!pool) {
81
+ return EMPTY;
82
+ }
83
+ const result = await pool.query(
84
+ 'SELECT cursor_at FROM ingest_checkpoints WHERE machine = $1 ORDER BY cursor_at DESC LIMIT 1',
85
+ [machine]
86
+ );
87
+ if (result.rows.length === 0) {
88
+ return EMPTY;
89
+ }
90
+ return covered(result.rows[0].cursor_at);
91
+ }
92
+
93
+ /**
94
+ * Coverage cursor: ingest checkpoint, else latest metric instant.
95
+ *
96
+ * Central ingest does not write ingest_checkpoints. When that table is empty,
97
+ * the latest sample is the covered bound so coverageSpans can paint present
98
+ * and absent instead of the whole window unknown.
99
+ *
100
+ * @param {object|undefined} pool - pg pool
101
+ * @param {Function} samples - async (args) => sample list
102
+ * @returns {Function} cursor port ({ machine, path }) => ISO instant
103
+ *
104
+ * @example
105
+ * coverageCursor(pool, ({ path }) => ports.samples({ path }))({ machine: 'm1' });
106
+ */
107
+ export default function coverageCursor(pool, samples) {
108
+ return async function bound(args) {
109
+ const machine = args.machine || args.path;
110
+ const mark = await checkpoint(pool, machine);
111
+ if (instant(mark) > 0) {
112
+ return mark;
113
+ }
114
+ return latest(await samples(args));
115
+ };
116
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import coverageOverlay from '../../domain/span/coverageOverlay.js';
2
3
  import coverageSpans from '../../domain/span/coverageSpans.js';
3
4
  import { toolText } from './mcpText.js';
4
5
 
@@ -34,13 +35,13 @@ async function coverOne(path, args, ports, tree) {
34
35
  const node = tree.resolve(path);
35
36
  const samples = await ports.samples({ from: args.from, path, to: args.to });
36
37
  const cursor = await ports.cursor({ path });
37
- const spans = coverageSpans(
38
+ const spans = coverageOverlay(coverageSpans(
38
39
  samples,
39
40
  args.from,
40
41
  args.to,
41
42
  node.silenceSeconds * 1000,
42
43
  cursorAt(cursor)
43
- );
44
+ ), await ports.segments({ from: args.from, path, to: args.to }));
44
45
  return {
45
46
  path,
46
47
  spans: spans.slice(0, 200),
@@ -0,0 +1,42 @@
1
+ /**
2
+ * One in-flight callback per SQL engine.
3
+ *
4
+ * ClickHouse and Postgres each keep a tail promise. A later job waits for
5
+ * that tail before it runs, so two query tools never execute together.
6
+ *
7
+ * @returns {object} run(engine, job)
8
+ *
9
+ * @example
10
+ * const flight = mcpFlight();
11
+ * await flight.run('clickhouse', () => query('SELECT 1'));
12
+ */
13
+ export default function mcpFlight() {
14
+ const tail = {
15
+ clickhouse: Promise.resolve(),
16
+ postgres: Promise.resolve()
17
+ };
18
+ return Object.freeze({
19
+ /**
20
+ * Runs job after the previous job for this engine settles.
21
+ *
22
+ * @param {string} engine - clickhouse or postgres
23
+ * @param {Function} job - callback
24
+ * @returns {Promise<*>} job result
25
+ */
26
+ run(engine, job) {
27
+ const prior = tail[engine];
28
+ let release;
29
+ const held = new Promise((resolve) => {
30
+ release = resolve;
31
+ });
32
+ tail[engine] = prior.then(() => {
33
+ return held;
34
+ }, () => {
35
+ return held;
36
+ });
37
+ return prior.then(job, job).finally(() => {
38
+ release();
39
+ });
40
+ }
41
+ });
42
+ }
@@ -72,7 +72,7 @@ function settle(outcome) {
72
72
  }
73
73
 
74
74
  /**
75
- * Times one tool call, counts it, and tracks the engine gauge.
75
+ * Times one tool call after the engine slot is held.
76
76
  *
77
77
  * @param {string} name - tool name
78
78
  * @param {object} args - tool arguments
@@ -82,9 +82,9 @@ function settle(outcome) {
82
82
  * @returns {Promise<object>} tool result
83
83
  *
84
84
  * @example
85
- * trackedTool('state', {}, {}, () => ({ content: [] }), ctx);
85
+ * invokeTool('state', {}, {}, () => ({ content: [] }), ctx);
86
86
  */
87
- async function trackedTool(name, args, extra, cb, ctx) {
87
+ async function invokeTool(name, args, extra, cb, ctx) {
88
88
  const engine = engineOf(name);
89
89
  const started = Date.now();
90
90
  ctx.metrics.begin(engine);
@@ -94,6 +94,32 @@ async function trackedTool(name, args, extra, cb, ctx) {
94
94
  return settle(outcome);
95
95
  }
96
96
 
97
+ /**
98
+ * Times one tool call, counts it, and tracks the engine gauge.
99
+ *
100
+ * SQL engines wait for the previous query on that engine so the in-flight
101
+ * gauge stays at one. Named plant tools are not queued.
102
+ *
103
+ * @param {string} name - tool name
104
+ * @param {object} args - tool arguments
105
+ * @param {object} extra - SDK extra
106
+ * @param {Function} cb - tool callback
107
+ * @param {object} ctx - journal context
108
+ * @returns {Promise<object>} tool result
109
+ *
110
+ * @example
111
+ * trackedTool('state', {}, {}, () => ({ content: [] }), ctx);
112
+ */
113
+ function trackedTool(name, args, extra, cb, ctx) {
114
+ const engine = engineOf(name);
115
+ if (engine === 'plant') {
116
+ return invokeTool(name, args, extra, cb, ctx);
117
+ }
118
+ return ctx.flight.run(engine, () => {
119
+ return invokeTool(name, args, extra, cb, ctx);
120
+ });
121
+ }
122
+
97
123
  /**
98
124
  * Times one resource read.
99
125
  *
@@ -1,3 +1,5 @@
1
+ import { clickhouseSchema, postgresSchema } from './mcpSchema.js';
2
+
1
3
  /**
2
4
  * Parent path of a node, or an empty string at the root.
3
5
  *
@@ -78,29 +80,69 @@ function body(uri, payload) {
78
80
  }
79
81
 
80
82
  /**
81
- * Registers plant topology and source bindings.
83
+ * Enumerates catalog entries without knowing their vocabulary.
82
84
  *
83
- * Topology has paths, kinds, titles, and liveness. Bindings map a path to a
84
- * topic and a unit. Gateway topics are not part of topology.
85
+ * @param {object} [catalog] - tagCatalog with entries()
86
+ * @returns {Array<object>} entries
87
+ *
88
+ * @example
89
+ * tags({ entries() { return []; } });
90
+ */
91
+ function tags(catalog) {
92
+ if (catalog && typeof catalog.entries === 'function') {
93
+ return catalog.entries();
94
+ }
95
+ return [];
96
+ }
97
+
98
+ /**
99
+ * Registers one JSON resource.
85
100
  *
86
101
  * @param {object} server - McpServer
87
- * @param {object} tree - namespaceTree
102
+ * @param {string} name - resource name
103
+ * @param {string} uri - resource URI
104
+ * @param {string} description - short list description
105
+ * @param {Function} payload - body factory
88
106
  * @returns {void}
89
107
  *
90
108
  * @example
91
- * registerResources(server, tree);
109
+ * publish(server, 'topology', 'plant://topology', 'Paths', () => []);
92
110
  */
93
- export default function registerResources(server, tree) {
94
- server.registerResource('topology', 'plant://topology', {
95
- description: 'Paths, kinds, titles, and liveness',
111
+ function publish(server, name, uri, description, payload) {
112
+ server.registerResource(name, uri, {
113
+ description,
96
114
  mimeType: 'application/json'
97
115
  }, () => {
98
- return body('plant://topology', topology(tree));
116
+ return body(uri, payload());
99
117
  });
100
- server.registerResource('bindings', 'plant://bindings', {
101
- description: 'Source topic and unit for each bound path',
102
- mimeType: 'application/json'
103
- }, () => {
104
- return body('plant://bindings', bindings(tree));
118
+ }
119
+
120
+ /**
121
+ * Registers plant topology, bindings, tags, and storage schemas.
122
+ *
123
+ * Topology has paths, kinds, titles, and liveness. Bindings map a path to a
124
+ * topic and a unit. Tags enumerate the catalog the plant package supplied.
125
+ * Schema resources list scada migration tables. Gateway topics stay out of
126
+ * topology. Operators omit card_uid. kind is opaque.
127
+ *
128
+ * @param {object} server - McpServer
129
+ * @param {object} tree - namespaceTree
130
+ * @param {object} [catalog] - tagCatalog with entries()
131
+ * @returns {void}
132
+ *
133
+ * @example
134
+ * registerResources(server, tree, catalog);
135
+ */
136
+ export default function registerResources(server, tree, catalog) {
137
+ publish(server, 'topology', 'plant://topology', 'Paths, kinds, titles, and liveness', () => {
138
+ return topology(tree);
139
+ });
140
+ publish(server, 'bindings', 'plant://bindings', 'Source topic and unit for each bound path', () => {
141
+ return bindings(tree);
142
+ });
143
+ publish(server, 'tags', 'plant://tags', 'Tag catalog entries from the plant package', () => {
144
+ return tags(catalog);
105
145
  });
146
+ publish(server, 'schema-postgres', 'plant://schema/postgres', 'Postgres tables from scada migrations', postgresSchema);
147
+ publish(server, 'schema-clickhouse', 'plant://schema/clickhouse', 'ClickHouse scada.metrics', clickhouseSchema);
106
148
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Column list for one schema table.
3
+ *
4
+ * @param {string} name - table name
5
+ * @param {Array<object>} columns - name and type
6
+ * @param {Array<string>} [notes] - short notes
7
+ * @returns {object} table
8
+ *
9
+ * @example
10
+ * table('alerts', [{ name: 'id', type: 'integer' }]);
11
+ */
12
+ function table(name, columns, notes) {
13
+ if (!notes) {
14
+ return { columns, name };
15
+ }
16
+ return { columns, name, notes };
17
+ }
18
+
19
+ /**
20
+ * Postgres tables from scada migrations, without operator credentials.
21
+ *
22
+ * kind is an opaque string. Plant brigade tables are not listed.
23
+ *
24
+ * @returns {object} schema body
25
+ *
26
+ * @example
27
+ * postgresSchema().tables[0].name;
28
+ */
29
+ export function postgresSchema() {
30
+ return {
31
+ tables: [
32
+ table('segments', [
33
+ { name: 'machine', type: 'text' },
34
+ { name: 'name', type: 'text' },
35
+ { name: 'start_time', type: 'timestamptz' },
36
+ { name: 'end_time', type: 'timestamptz' },
37
+ { name: 'duration', type: 'float' },
38
+ { name: 'options', type: 'text' },
39
+ { name: 'tags', type: 'text' },
40
+ { name: 'properties', type: 'text' },
41
+ { name: 'resolved', type: 'boolean' },
42
+ { name: 'consumed', type: 'boolean' },
43
+ { name: 'kind', type: 'text' },
44
+ { name: 'updated_at', type: 'timestamptz' }
45
+ ], ['kind is an opaque string']),
46
+ table('operations', [
47
+ { name: 'machine', type: 'text' },
48
+ { name: 'occurred_at', type: 'timestamptz' },
49
+ { name: 'kind', type: 'text' },
50
+ { name: 'key', type: 'text' },
51
+ { name: 'payload', type: 'jsonb' }
52
+ ], ['kind is an opaque string']),
53
+ table('alerts', [
54
+ { name: 'id', type: 'integer' },
55
+ { name: 'name', type: 'text' },
56
+ { name: 'message', type: 'text' },
57
+ { name: 'machine', type: 'text' },
58
+ { name: 'severity', type: 'text' },
59
+ { name: 'timestamp', type: 'timestamptz' },
60
+ { name: 'acknowledged', type: 'boolean' }
61
+ ]),
62
+ table('user_decisions', [
63
+ { name: 'machine', type: 'text' },
64
+ { name: 'start_time', type: 'timestamptz' },
65
+ { name: 'username', type: 'text' },
66
+ { name: 'payload', type: 'text' },
67
+ { name: 'operator_id', type: 'integer' },
68
+ { name: 'decided_at', type: 'timestamptz' }
69
+ ]),
70
+ table('operators', [
71
+ { name: 'id', type: 'integer' },
72
+ { name: 'first_name', type: 'text' },
73
+ { name: 'last_name', type: 'text' },
74
+ { name: 'display_name', type: 'text' }
75
+ ])
76
+ ]
77
+ };
78
+ }
79
+
80
+ /**
81
+ * ClickHouse metrics table. topic is the only series key.
82
+ *
83
+ * @returns {object} schema body
84
+ *
85
+ * @example
86
+ * clickhouseSchema().tables[0].name;
87
+ */
88
+ export function clickhouseSchema() {
89
+ return {
90
+ tables: [
91
+ table('scada.metrics', [
92
+ { name: 'topic', type: 'String' },
93
+ { name: 'ts', type: 'DateTime64(3)' },
94
+ { name: 'value', type: 'Float64' }
95
+ ], ['topic is the only series key'])
96
+ ]
97
+ };
98
+ }
@@ -1,7 +1,25 @@
1
1
  import plant from '../domain/plant/plant.js';
2
2
  import initialized from '../domain/shared/initialized.js';
3
+ import pgAlerts from '../infrastructure/persistence/pg/alerts.js';
3
4
  import postgresPool from '../infrastructure/persistence/postgresPool.js';
4
5
 
6
+ /**
7
+ * Plant extras: injected ports, or pgAlerts when a pool exists.
8
+ *
9
+ * @param {object} options - optional operations and alerts
10
+ * @param {object|undefined} pool - pg pool
11
+ * @returns {object} plant options
12
+ *
13
+ * @example
14
+ * extras({ operations }, pool);
15
+ */
16
+ function extras(options, pool) {
17
+ return {
18
+ operations: options.operations,
19
+ alerts: options.alerts || (pool ? pgAlerts(pool) : undefined)
20
+ };
21
+ }
22
+
5
23
  /**
6
24
  * Generic plant factory wiring sensors and a shop factory over a connection.
7
25
  *
@@ -32,5 +50,5 @@ export default async function metricsPlant(machines, options) {
32
50
  segments: options.segments,
33
51
  shopName: options.shopName
34
52
  });
35
- return plant(initialized({ [shop.name()]: shop }, Object.values));
53
+ return plant(initialized({ [shop.name()]: shop }, Object.values), extras(options, pool));
36
54
  }
@@ -73,6 +73,13 @@ function writePort(name, decisions, owners) {
73
73
  }, owners)(name);
74
74
  }
75
75
 
76
+ function demandWrite(name, decisions, owners) {
77
+ if (!decisions) {
78
+ throw new Error(`Timeline write requires userDecisions for ${name}`);
79
+ }
80
+ return writePort(name, decisions, owners);
81
+ }
82
+
76
83
  function ownsLocal(owners, name) {
77
84
  if (!owners) {
78
85
  return true;
@@ -153,23 +160,23 @@ async function answer(scope, requestId, body, audit) {
153
160
  }
154
161
 
155
162
  /**
156
- * Builds a machine timeline from PostgreSQL persistence and STOMP user decisions.
163
+ * Builds a machine timeline from PostgreSQL when a pool exists.
157
164
  *
158
- * Optional owners registry routes retag/respond to an edge HTTP plant API.
165
+ * Reads do not need userDecisions. retag and respond throw without it.
166
+ * Optional owners registry routes those writes to an edge HTTP plant API.
159
167
  *
160
168
  * @param {string} name - machine id
161
- * @param {object} options - pool, userDecisions, optional owners
169
+ * @param {object} options - pool, optional userDecisions and owners
162
170
  * @returns {object} timeline port with list, rowAt, pending, stream, retag, respond, bus
163
171
  *
164
172
  * @example
165
- * const tl = shopWithTimeline('machine1', { pool, userDecisions, owners });
173
+ * const tl = shopWithTimeline('machine1', { pool });
166
174
  */
167
175
  export default function shopWithTimeline(name, options) {
168
176
  const { pool, userDecisions: decisions, owners, segments } = options;
169
- if (pool && decisions) {
177
+ if (pool) {
170
178
  const bus = pubsub();
171
179
  const read = pgTimeline(pool, name);
172
- const write = writePort(name, decisions, owners);
173
180
  const port = timeline(read, bus);
174
181
  return {
175
182
  list: port.list,
@@ -179,6 +186,7 @@ export default function shopWithTimeline(name, options) {
179
186
  stream: port.stream,
180
187
  bus,
181
188
  async retag(start, tags, properties, audit) {
189
+ const write = demandWrite(name, decisions, owners);
182
190
  if (ownsLocal(owners, name)) {
183
191
  await confirm(pool, {
184
192
  machine: name,
@@ -192,6 +200,7 @@ export default function shopWithTimeline(name, options) {
192
200
  bus.emit({ type: 'resolved', segment: resolvedStub(start, tags, properties), audit });
193
201
  },
194
202
  respond(requestId, body, audit) {
203
+ const write = demandWrite(name, decisions, owners);
195
204
  return answer({ pool, owners, name, segments, write, bus }, requestId, body, audit);
196
205
  }
197
206
  };
@@ -3,7 +3,7 @@
3
3
  * Provides initialization for all contained shops.
4
4
  *
5
5
  * @param {object} shops - initialized list of shops
6
- * @param {object} [options] - operations, topology
6
+ * @param {object} [options] - operations, alerts, topology
7
7
  * @returns {object} plant with shops property and init method
8
8
  *
9
9
  * @example
@@ -14,6 +14,7 @@ export default function plant(shops, options = {}) {
14
14
  return {
15
15
  shops,
16
16
  operations: options.operations,
17
+ alerts: options.alerts,
17
18
  topology: options.topology,
18
19
  init() {
19
20
  shops.init();
@@ -0,0 +1,164 @@
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 overlay instant is not a time: ${value}`);
33
+ }
34
+ return at;
35
+ }
36
+
37
+ /**
38
+ * Reads a segment bound from one of the accepted field names.
39
+ *
40
+ * @param {object} row - segment
41
+ * @param {Array<string>} names - field names
42
+ * @returns {Date|number|string} instant
43
+ *
44
+ * @example
45
+ * field({ start_time: '2024-11-02T03:04:05.137Z' }, ['start_time', 'from']);
46
+ */
47
+ function field(row, names) {
48
+ const name = names.find((key) => {
49
+ return Object.hasOwn(row, key);
50
+ });
51
+ if (!name) {
52
+ throw new Error(`Coverage overlay bound is missing: ${names.join(',')}`);
53
+ }
54
+ return row[name];
55
+ }
56
+
57
+ /**
58
+ * Normalizes a timeline row to epoch bounds and a name.
59
+ *
60
+ * @param {object} row - segment
61
+ * @returns {{from: number, name: string, to: number}} interval
62
+ *
63
+ * @example
64
+ * bounds({ name: 'off', start_time: 0, end_time: 1 });
65
+ */
66
+ function bounds(row) {
67
+ return {
68
+ from: stamp(field(row, ['start_time', 'from', 'start'])),
69
+ name: typeof row.name === 'string' ? row.name : '',
70
+ to: stamp(field(row, ['end_time', 'to', 'end']))
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Unique sorted cut points of a span and the intervals that cross it.
76
+ *
77
+ * @param {object} span - coverage span
78
+ * @param {Array<object>} rows - normalized intervals
79
+ * @returns {Array<number>} epochs
80
+ *
81
+ * @example
82
+ * cuts({ from: 'a', to: 'b' }, []);
83
+ */
84
+ function cuts(span, rows) {
85
+ const start = stamp(span.from);
86
+ const stop = stamp(span.to);
87
+ const marks = [start, stop];
88
+ rows.forEach((row) => {
89
+ if (row.from > start && row.from < stop) {
90
+ marks.push(row.from);
91
+ }
92
+ if (row.to > start && row.to < stop) {
93
+ marks.push(row.to);
94
+ }
95
+ });
96
+ return Array.from(new Set(marks)).sort((left, right) => {
97
+ return left - right;
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Timeline name that overlaps an open interval, or an empty string.
103
+ *
104
+ * @param {Array<object>} rows - normalized intervals
105
+ * @param {number} from - start epoch
106
+ * @param {number} to - end epoch
107
+ * @returns {string} name
108
+ *
109
+ * @example
110
+ * title([{ from: 0, name: 'off', to: 10 }], 0, 10);
111
+ */
112
+ function title(rows, from, to) {
113
+ const hit = rows.find((row) => {
114
+ return row.from < to && row.to > from;
115
+ });
116
+ if (!hit) {
117
+ return '';
118
+ }
119
+ return hit.name;
120
+ }
121
+
122
+ /**
123
+ * Splits one coverage span on overlapping timeline names.
124
+ *
125
+ * @param {object} span - coverage span
126
+ * @param {Array<object>} rows - normalized intervals
127
+ * @returns {Array<object>} painted spans
128
+ *
129
+ * @example
130
+ * paint({ from: 'a', status: 'present', to: 'b' }, []);
131
+ */
132
+ function paint(span, rows) {
133
+ const marks = cuts(span, rows);
134
+ return marks.slice(0, -1).map((from, index) => {
135
+ const to = marks[index + 1];
136
+ return {
137
+ from: new Date(from).toISOString(),
138
+ phase: title(rows, from, to),
139
+ status: span.status,
140
+ to: new Date(to).toISOString()
141
+ };
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Paints overlapping timeline names onto coverage spans.
147
+ *
148
+ * coverageSpans stays a status classifier. This join splits a span wherever
149
+ * a timeline interval starts or ends and copies that interval name as phase.
150
+ *
151
+ * @param {Array<object>} spans - coverage spans
152
+ * @param {Array<object>} segments - timeline rows
153
+ * @returns {Array<object>} spans with phase
154
+ *
155
+ * @example
156
+ * coverageOverlay([{ from, status: 'present', to }], [{ name, start_time, end_time }]);
157
+ */
158
+ export default function coverageOverlay(spans, segments) {
159
+ const rows = (Array.isArray(segments) ? segments : []).map(bounds);
160
+ const list = Array.isArray(spans) ? spans : [];
161
+ return list.flatMap((span) => {
162
+ return paint(span, rows);
163
+ });
164
+ }
@@ -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 = [];