@yarkivaev/scada 2.3.47 → 2.3.49

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
@@ -200,14 +200,18 @@ import stateDataFake from '@yarkivaev/scada/stateDataFake';
200
200
 
201
201
  ## Package boundary
202
202
 
203
- | This package (`@yarkivaev/scada`) | Downstream plant package |
203
+ **Hard rule:** this package is factory-agnostic. It must not contain Sokol melting/bath domain, chem-analysis UI, HMI copy (e.g. Болото), `weight_after` fold semantics, or plant-specific sensors/routes. Those belong in `sokol-scada` (or another plant package).
204
+
205
+ Opaque operation `kind` strings and generic ports (`latestForMachine`, list/upsert/stream) are allowed; never treat a kind as a first-class domain type here.
206
+
207
+ | This package (`@yarkivaev/scada`) | Downstream plant package (`sokol-scada`, …) |
204
208
  |------------------------|--------------------------|
205
- | Generic plant, timeline, alerts, ingest | Site domain, sensors, HMI strings, tag taxonomy |
209
+ | Generic plant, shop, machine, timeline, alerts, operations ports | Melting bath, chem, cycles HMI, site sensors |
206
210
  | Empty/injectable alert `translations` | Localized rule messages |
207
- | Opaque operation `kind` strings | Site-specific kinds (`sample`, `load`, …) |
208
- | `plantApi`, `siteServer`, `supervisorSink` | Site bins and routes |
211
+ | Opaque operation `kind` strings | Site kinds (`bath`, `chem`, …) and fold/prefix logic |
212
+ | `plantApi`, `siteServer`, `supervisorSink` | Site bins, routes, `bathSensor`, reports |
209
213
  | Export ports `Query` / `Stream` / `Sink` / `Job` | Export jobs + destination adapters |
210
- | ClickHouse + PG adapters | Plant wiring (`metricsPlant`, shops, machines) |
214
+ | ClickHouse + PG adapters | Plant wiring (`sokolPlant` / `edgePlant`) |
211
215
 
212
216
  Depend on a pinned version (`"@yarkivaev/scada": "…#v2.3.46"`), not a local path, in downstream packages.
213
217
 
@@ -0,0 +1,2 @@
1
+ CREATE INDEX IF NOT EXISTS idx_operations_machine_kind_time
2
+ ON operations (machine, kind, occurred_at);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.3.47",
3
+ "version": "2.3.49",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -6,9 +6,9 @@ import pubsub from '../domain/shared/pubsub.js';
6
6
  *
7
7
  * Optional kindSources inject non-PG kinds into listForMachine merges.
8
8
  *
9
- * @param {object} persistence - operations store with upsert, get, remove, listForMachine
9
+ * @param {object} persistence - operations store with upsert, get, remove, listForMachine, latestForMachine
10
10
  * @param {object} [kindSources] - map of kind to { list(machineId, range) }
11
- * @returns {object} operations with listForMachine, upsert, get, remove, and stream
11
+ * @returns {object} operations with listForMachine, latestForMachine, upsert, get, remove, and stream
12
12
  *
13
13
  * @example
14
14
  * const ops = plantOperations(dataAccess.operations, { temp: temperaturePort });
@@ -32,15 +32,17 @@ function resolveKinds(kinds, sources) {
32
32
  *
33
33
  * listForMachine merges requested kinds from injectable sources and Postgres,
34
34
  * sorted by occurred_at. Omitted kinds default to injectable source keys.
35
+ * latestForMachine reads only from persistence (single kind).
35
36
  *
36
- * @param {object} persistence - store with upsert, get, remove, listForMachine
37
+ * @param {object} persistence - store with upsert, get, remove, listForMachine, latestForMachine
37
38
  * @param {object} bus - pubsub instance with stream and emit methods
38
39
  * @param {object} [kindSources] - map of kind to { list(machineId, range) }
39
- * @returns {object} operations with listForMachine, upsert, get, remove, stream
40
+ * @returns {object} operations with listForMachine, latestForMachine, upsert, get, remove, stream
40
41
  *
41
42
  * @example
42
43
  * const ops = operations(store, bus, { temp: temperaturePort });
43
- * await ops.listForMachine('m1', ['chem', 'temp'], { from: new Date() });
44
+ * await ops.listForMachine('m1', ['sample', 'temp'], { from: new Date() });
45
+ * await ops.latestForMachine('m1', 'sample', { to: new Date() });
44
46
  * await ops.remove('m1', 'nb-1');
45
47
  */
46
48
  export default function operations(persistence, bus, kindSources) {
@@ -59,6 +61,9 @@ export default function operations(persistence, bus, kindSources) {
59
61
  });
60
62
  });
61
63
  },
64
+ latestForMachine(machineId, kind, bound) {
65
+ return persistence.latestForMachine(machineId, kind, bound);
66
+ },
62
67
  upsert(item) {
63
68
  const updatedAt = new Date();
64
69
  const row = stampRow(item, updatedAt);
@@ -48,7 +48,7 @@ function rangeQuery(options) {
48
48
  * @example
49
49
  * const machine = machineClient(baseUrl, 'm1', fetch, EventSource, logger);
50
50
  * const info = await machine.info();
51
- * await machine.createOperation({ kind: 'bath', payload: { action: 'load' } });
51
+ * await machine.createOperation({ kind: 'load', payload: { action: 'add' } });
52
52
  * await machine.updateOperation(key, { payload: { action: 'load' } });
53
53
  * await machine.deleteOperation(key);
54
54
  */
@@ -121,7 +121,7 @@ function deleteRequest(request, key, fields) {
121
121
  *
122
122
  * @example
123
123
  * const ops = machineOperationsClient(baseUrl, request, EventSource, logger);
124
- * await ops.createOperation({ kind: 'bath', payload: {}, operatorId: 2 });
124
+ * await ops.createOperation({ kind: 'load', payload: {}, operatorId: 2 });
125
125
  * await ops.operationDecisions(key);
126
126
  */
127
127
  export default function machineOperationsClient(baseUrl, request, eventSource, logger) {
@@ -12,7 +12,7 @@ function iso(value) {
12
12
  * @returns {object} API item with external_key and source_updated_at
13
13
  *
14
14
  * @example
15
- * operationJson({ key: 'nb-1', occurred_at: new Date(), kind: 'chem', payload: {}, machine: 'm1' });
15
+ * operationJson({ key: 'nb-1', occurred_at: new Date(), kind: 'sample', payload: {}, machine: 'm1' });
16
16
  */
17
17
  export default function operationJson(row) {
18
18
  const occurred = iso(row.occurred_at);
@@ -45,7 +45,7 @@ async function readJson(res) {
45
45
  *
46
46
  * @example
47
47
  * const port = httpOperations({ baseUrl: 'http://edge/api/v1' }, 'm2');
48
- * await port.create({ kind: 'bath', payload: {}, operatorId: 2 });
48
+ * await port.create({ kind: 'load', payload: {}, operatorId: 2 });
49
49
  */
50
50
  export default function httpOperations(site, machineId) {
51
51
  const base = trimBase(site.baseUrl);
@@ -37,6 +37,41 @@ function filterList(store, machineId, kind, range) {
37
37
  });
38
38
  }
39
39
 
40
+ /**
41
+ * Picks the latest matching row for a machine and kind at a time bound.
42
+ *
43
+ * @param {object} store - shared mutable store with operations array
44
+ * @param {string} machineId - machine identifier
45
+ * @param {string} kind - operation kind
46
+ * @param {{ to?: Date, before?: Date }} bound - inclusive to or exclusive before
47
+ * @returns {object|null} latest row or null
48
+ */
49
+ function filterLatest(store, machineId, kind, bound) {
50
+ const range = bound || {};
51
+ const before = range.before ? new Date(range.before).getTime() : null;
52
+ const to = range.to ? new Date(range.to).getTime() : null;
53
+ const matches = store.operations.filter((row) => {
54
+ if (row.machine !== machineId || row.kind !== kind) {
55
+ return false;
56
+ }
57
+ const at = new Date(row.occurred_at).getTime();
58
+ if (before !== null && at >= before) {
59
+ return false;
60
+ }
61
+ if (to !== null && at > to) {
62
+ return false;
63
+ }
64
+ return true;
65
+ }).sort((left, right) => {
66
+ const delta = new Date(right.occurred_at) - new Date(left.occurred_at);
67
+ if (delta !== 0) {
68
+ return delta;
69
+ }
70
+ return String(right.key).localeCompare(String(left.key));
71
+ });
72
+ return matches[0] || null;
73
+ }
74
+
40
75
  /**
41
76
  * In-memory operations state port for tests and local runs.
42
77
  *
@@ -82,6 +117,9 @@ export default function operationStateMemory(store) {
82
117
  },
83
118
  listForMachine(machineId, kind, range) {
84
119
  return Promise.resolve(filterList(store, machineId, kind, range));
120
+ },
121
+ latestForMachine(machineId, kind, bound) {
122
+ return Promise.resolve(filterLatest(store, machineId, kind, bound));
85
123
  }
86
124
  };
87
125
  }
@@ -38,6 +38,33 @@ function listForMachine(pool, machineId, kind, range) {
38
38
  });
39
39
  }
40
40
 
41
+ /**
42
+ * Loads the latest operation for a machine and kind at or before a bound.
43
+ *
44
+ * @param {object} pool - pg pool
45
+ * @param {string} machineId - machine identifier
46
+ * @param {string} kind - operation kind
47
+ * @param {{ to?: Date, before?: Date }} bound - inclusive to or exclusive before
48
+ * @returns {Promise<object|null>} latest row or null
49
+ */
50
+ function latestForMachine(pool, machineId, kind, bound) {
51
+ const range = bound || {};
52
+ let sql = `SELECT machine, occurred_at, kind, key, payload
53
+ FROM operations WHERE machine = $1 AND kind = $2`;
54
+ const prm = [machineId, kind];
55
+ if (range.before) {
56
+ prm.push(range.before);
57
+ sql += ` AND occurred_at < $${prm.length}`;
58
+ } else if (range.to) {
59
+ prm.push(range.to);
60
+ sql += ` AND occurred_at <= $${prm.length}`;
61
+ }
62
+ sql += ' ORDER BY occurred_at DESC, key DESC LIMIT 1';
63
+ return pool.query(sql, prm).then((result) => {
64
+ return result.rows[0] || null;
65
+ });
66
+ }
67
+
41
68
  function missing(machineId, key) {
42
69
  return new Error(`operation '${key}' not found for machine '${machineId}'`);
43
70
  }
@@ -70,11 +97,12 @@ async function removeOperation(pool, machineId, key) {
70
97
  * PostgreSQL operations persistence port for generic machine operations.
71
98
  *
72
99
  * @param {object} pool - pg pool
73
- * @returns {object} operations port with upsert, get, remove, and listForMachine
100
+ * @returns {object} operations port with upsert, get, remove, listForMachine, latestForMachine
74
101
  *
75
102
  * @example
76
103
  * const store = operationStatePg(pool);
77
- * await store.upsert({ machine: 'm1', key: 'nb-1', kind: 'chem', ... });
104
+ * await store.upsert({ machine: 'm1', key: 'nb-1', kind: 'sample', ... });
105
+ * await store.latestForMachine('m1', 'sample', { to: new Date() });
78
106
  * await store.remove('m1', 'nb-1');
79
107
  */
80
108
  export default function operationStatePg(pool) {
@@ -90,6 +118,9 @@ export default function operationStatePg(pool) {
90
118
  },
91
119
  listForMachine(machineId, kind, range) {
92
120
  return listForMachine(pool, machineId, kind, range);
121
+ },
122
+ latestForMachine(machineId, kind, bound) {
123
+ return latestForMachine(pool, machineId, kind, bound);
93
124
  }
94
125
  };
95
126
  }
@@ -12,7 +12,7 @@
12
12
  * const catalog = userDecisionsFromPg(pool);
13
13
  * await catalog.insert({
14
14
  * machine: 'm1', startTime: new Date(), username: 'Ivan',
15
- * decidedAt: new Date(), payload: { kind: 'operation_op', verb: 'create', key: 'bath:m1:1' }
15
+ * decidedAt: new Date(), payload: { kind: 'operation_op', verb: 'create', key: 'op:m1:1' }
16
16
  * });
17
17
  */
18
18
  function mapRow(row) {
@@ -55,8 +55,8 @@ async function decode(parsed, collector) {
55
55
  *
56
56
  * @example
57
57
  * const codec = operationCodec(collector);
58
- * await codec.accept(Buffer.from('{"machine":"m1","occurred_at":"2024-06-01T10:00:00.000Z","kind":"chem","external_key":"nb-1","payload":{}}'));
59
- * await codec.accept(Buffer.from('{"type":"deleted","machine":"m1","occurred_at":"2024-06-01T10:00:00.000Z","kind":"bath","external_key":"k"}'));
58
+ * await codec.accept(Buffer.from('{"machine":"m1","occurred_at":"2024-06-01T10:00:00.000Z","kind":"sample","external_key":"nb-1","payload":{}}'));
59
+ * await codec.accept(Buffer.from('{"type":"deleted","machine":"m1","occurred_at":"2024-06-01T10:00:00.000Z","kind":"load","external_key":"k"}'));
60
60
  */
61
61
  export default function operationCodec(collector) {
62
62
  if (!collector || typeof collector.accept !== 'function') {
@@ -8,8 +8,8 @@ import processingErrorLog from '../ingest/processingErrorLog.js';
8
8
  *
9
9
  * @example
10
10
  * const sink = operationSyncSink(dataAccess.operations);
11
- * await sink.accept({ machine: 'm1', occurred_at: new Date(), kind: 'chem', key: 'nb-1', payload: {} });
12
- * await sink.remove({ machine: 'm1', kind: 'chem', key: 'nb-1', occurred_at: new Date() });
11
+ * await sink.accept({ machine: 'm1', occurred_at: new Date(), kind: 'sample', key: 'nb-1', payload: {} });
12
+ * await sink.remove({ machine: 'm1', kind: 'sample', key: 'nb-1', occurred_at: new Date() });
13
13
  */
14
14
  export default function operationSyncSink(operations) {
15
15
  return {