@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 +9 -5
- package/db/migrations/V0006__operations_machine_kind_time.sql +2 -0
- package/package.json +1 -1
- package/src/application/plantOperations.js +2 -2
- package/src/domain/operation/operations.js +8 -3
- package/src/infrastructure/client/machineClient.js +1 -1
- package/src/infrastructure/client/machineOperationsClient.js +1 -1
- package/src/infrastructure/http/plant/json/operationJson.js +1 -1
- package/src/infrastructure/messaging/ownership/httpOperations.js +1 -1
- package/src/infrastructure/persistence/memory/operations.js +38 -0
- package/src/infrastructure/persistence/pg/operations.js +33 -2
- package/src/infrastructure/persistence/pg/userDecisions.js +1 -1
- package/src/infrastructure/sync/operationCodec.js +2 -2
- package/src/infrastructure/sync/operationSyncSink.js +2 -2
package/README.md
CHANGED
|
@@ -200,14 +200,18 @@ import stateDataFake from '@yarkivaev/scada/stateDataFake';
|
|
|
200
200
|
|
|
201
201
|
## Package boundary
|
|
202
202
|
|
|
203
|
-
|
|
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,
|
|
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
|
|
208
|
-
| `plantApi`, `siteServer`, `supervisorSink` | Site bins
|
|
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 (`
|
|
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
|
|
package/package.json
CHANGED
|
@@ -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', ['
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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,
|
|
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: '
|
|
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: '
|
|
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":"
|
|
59
|
-
* await codec.accept(Buffer.from('{"type":"deleted","machine":"m1","occurred_at":"2024-06-01T10:00:00.000Z","kind":"
|
|
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: '
|
|
12
|
-
* await sink.remove({ machine: 'm1', kind: '
|
|
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 {
|