@yarkivaev/scada 2.3.86 → 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.
package/README.md CHANGED
@@ -132,7 +132,7 @@ Main entry (`import { … } from '@yarkivaev/scada'`):
132
132
  | `stompTimeline`, `stompAlerts`, `userDecisions` | STOMP messaging |
133
133
  | `machineOwners`, `ownerTimeline`, `httpTimeline`, `httpOperations` | Owner-routed timeline and operations write (local or edge HTTP) |
134
134
  | `mqttMetrics`, `modbusMqtt`, `activityTracking`, `amqpMetricsIngest` | Ingest pipelines |
135
- | `analyticsMcp`, `namespaceTree`, `coverageSpans`, `sqlGuard` | Analytics MCP, path index, coverage spans, read-only SQL guard |
135
+ | `analyticsMcp`, `namespaceTree`, `coverageSpans`, `coverageCursor`, `sqlGuard` | Analytics MCP, path index, coverage spans, checkpoint-or-sample cursor, read-only SQL guard |
136
136
 
137
137
  Subpath exports:
138
138
 
package/index.js CHANGED
@@ -88,5 +88,6 @@ export { default as siteSyncTargets } from './src/application/sync/siteSyncTarge
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
90
  export { default as coverageSpans } from './src/domain/span/coverageSpans.js';
91
+ export { default as coverageCursor } from './src/application/mcp/coverageCursor.js';
91
92
  export { default as sqlGuard } from './src/domain/sql/sqlGuard.js';
92
93
  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.87",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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,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();
@@ -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 = [];