@yarkivaev/scada 2.3.50 → 2.3.52

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.3.50",
3
+ "version": "2.3.52",
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, latestForMachine
9
+ * @param {object} persistence - operations store with upsert, upsertMany, get, remove, listForMachine, latestForMachine
10
10
  * @param {object} [kindSources] - map of kind to { list(machineId, range) }
11
- * @returns {object} operations with listForMachine, latestForMachine, upsert, get, remove, and stream
11
+ * @returns {object} operations with listForMachine, latestForMachine, upsert, upsertMany, get, remove, and stream
12
12
  *
13
13
  * @example
14
14
  * const ops = plantOperations(dataAccess.operations, { temp: temperaturePort });
@@ -27,6 +27,34 @@ function resolveKinds(kinds, sources) {
27
27
  return [kinds];
28
28
  }
29
29
 
30
+ function announce(bus, result, row) {
31
+ bus.emit({
32
+ type: result.created ? 'created' : 'updated',
33
+ operation: row
34
+ });
35
+ }
36
+
37
+ function writeOne(persistence, bus, item) {
38
+ const row = stampRow(item, new Date());
39
+ return persistence.upsert(row).then((result) => {
40
+ announce(bus, result, row);
41
+ });
42
+ }
43
+
44
+ function writeMany(persistence, bus, items) {
45
+ if (typeof persistence.upsertMany !== 'function') {
46
+ throw new Error('Operations persistence must have an upsertMany() method');
47
+ }
48
+ const rows = items.map((item) => {
49
+ return stampRow(item, new Date());
50
+ });
51
+ return persistence.upsertMany(rows).then((results) => {
52
+ rows.forEach((row, index) => {
53
+ announce(bus, results[index], row);
54
+ });
55
+ });
56
+ }
57
+
30
58
  /**
31
59
  * Operations wired to persistence, pubsub, and optional non-PG kind sources.
32
60
  *
@@ -37,7 +65,7 @@ function resolveKinds(kinds, sources) {
37
65
  * @param {object} persistence - store with upsert, get, remove, listForMachine, latestForMachine
38
66
  * @param {object} bus - pubsub instance with stream and emit methods
39
67
  * @param {object} [kindSources] - map of kind to { list(machineId, range) }
40
- * @returns {object} operations with listForMachine, latestForMachine, upsert, get, remove, stream
68
+ * @returns {object} operations with listForMachine, latestForMachine, upsert, upsertMany, get, remove, stream
41
69
  *
42
70
  * @example
43
71
  * const ops = operations(store, bus, { temp: temperaturePort });
@@ -65,12 +93,10 @@ export default function operations(persistence, bus, kindSources) {
65
93
  return persistence.latestForMachine(machineId, kind, bound);
66
94
  },
67
95
  upsert(item) {
68
- const updatedAt = new Date();
69
- const row = stampRow(item, updatedAt);
70
- return persistence.upsert(row).then((result) => {
71
- const type = result.created ? 'created' : 'updated';
72
- bus.emit({ type, operation: row });
73
- });
96
+ return writeOne(persistence, bus, item);
97
+ },
98
+ upsertMany(items) {
99
+ return writeMany(persistence, bus, items);
74
100
  },
75
101
  get(machineId, key) {
76
102
  return persistence.get(machineId, key);
@@ -134,6 +134,14 @@ export default function machineOperationsClient(baseUrl, request, eventSource, l
134
134
  createOperation(fields) {
135
135
  return request('/operations', payload('POST', createBody(fields)));
136
136
  },
137
+ createOperations(list) {
138
+ const items = (list || []).map(createBody);
139
+ const data = { items };
140
+ if (list && list.length > 0) {
141
+ attachAudit(data, list[0]);
142
+ }
143
+ return request('/operations/batch', payload('POST', data));
144
+ },
137
145
  updateOperation(key, fields) {
138
146
  return request(
139
147
  `/operations/${encodeURIComponent(key)}`,
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Normalizes a tags or options field that may be an array or JSON string.
3
+ *
4
+ * @param {unknown} raw - stored field
5
+ * @returns {Array<string>} id list
6
+ */
7
+ function asIds(raw) {
8
+ if (Array.isArray(raw)) {
9
+ return raw.filter((id) => {
10
+ return typeof id === 'string';
11
+ });
12
+ }
13
+ if (typeof raw === 'string' && raw.length > 0) {
14
+ const parsed = JSON.parse(raw);
15
+ return asIds(parsed);
16
+ }
17
+ return [];
18
+ }
19
+
20
+ /**
21
+ * Returns whether requested tags are inside published options or already standing.
22
+ * Empty options mean no restriction so older publishers keep working.
23
+ *
24
+ * @param {unknown} options - published option ids
25
+ * @param {unknown} standing - tags already on the segment
26
+ * @param {unknown} tags - tags the operator wants to write
27
+ * @returns {boolean} true when every requested tag is allowed
28
+ * @example
29
+ * allowedSegmentTags(['load'], [], ['load'])
30
+ */
31
+ export default function allowedSegmentTags(options, standing, tags) {
32
+ const allow = asIds(options);
33
+ if (allow.length === 0) {
34
+ return true;
35
+ }
36
+ const held = new Set(asIds(standing));
37
+ const published = new Set(allow);
38
+ return asIds(tags).every((id) => {
39
+ return published.has(id) || held.has(id);
40
+ });
41
+ }
@@ -0,0 +1,133 @@
1
+ import machineInPlant from '../../../../application/machineInPlant.js';
2
+ import operationJson from '../json/operationJson.js';
3
+ import { decisionRow, stampPayload } from '../operationAudit.js';
4
+ import { draftsFromBatch } from './operationDrafts.js';
5
+ import httpOperations from '../../../messaging/ownership/httpOperations.js';
6
+ import { errorResponse, jsonResponse, readBody, sendRouteError } from '@yarkivaev/simple-server';
7
+
8
+ function isMissing(err) {
9
+ return typeof err.message === 'string' && err.message.includes('not found for machine');
10
+ }
11
+
12
+ function sendFailure(gate, res, err) {
13
+ if (gate && gate.sendError(res, err)) {
14
+ return;
15
+ }
16
+ if (err.routeCode && err.routeStatus) {
17
+ errorResponse(err.routeCode, err.message, err.routeStatus).send(res);
18
+ return;
19
+ }
20
+ if (isMissing(err)) {
21
+ errorResponse('NOT_FOUND', err.message, 404).send(res);
22
+ return;
23
+ }
24
+ sendRouteError(res, err);
25
+ }
26
+
27
+ function machineMissing(plant, machineId, res) {
28
+ const result = machineInPlant(plant, machineId);
29
+ if (!result || !plant.operations) {
30
+ errorResponse('NOT_FOUND', `Machine '${machineId}' not found`, 404).send(res);
31
+ return true;
32
+ }
33
+ return false;
34
+ }
35
+
36
+ async function record(decisions, machine, item, audit, verb) {
37
+ if (!decisions || typeof decisions.insert !== 'function') {
38
+ return;
39
+ }
40
+ await decisions.insert(decisionRow(machine, item, audit, verb));
41
+ }
42
+
43
+ async function readJson(req) {
44
+ if (typeof req.on !== 'function') {
45
+ return {};
46
+ }
47
+ const raw = await readBody(req);
48
+ if (!raw || String(raw).trim().length === 0) {
49
+ return {};
50
+ }
51
+ return JSON.parse(raw);
52
+ }
53
+
54
+ function edgePort(owners, machineId) {
55
+ if (!owners || typeof owners.resolve !== 'function') {
56
+ return undefined;
57
+ }
58
+ const owner = owners.resolve(machineId);
59
+ if (!owner || owner.kind !== 'edge') {
60
+ return undefined;
61
+ }
62
+ return httpOperations(owner, machineId);
63
+ }
64
+
65
+ function ownerItem(machineId, response, parsed) {
66
+ return {
67
+ machine: machineId,
68
+ key: (response && (response.external_key || response.key)) || parsed.key,
69
+ kind: (response && response.kind) || parsed.kind,
70
+ occurred_at: (response && response.occurred_at) || parsed.occurred_at || new Date(),
71
+ payload: (response && response.payload) || parsed.payload
72
+ };
73
+ }
74
+
75
+ function forwardBatch(parsed, audit) {
76
+ const body = { items: parsed.items };
77
+ if (audit.id !== undefined && audit.id !== null) {
78
+ body.operatorId = audit.id;
79
+ }
80
+ return body;
81
+ }
82
+
83
+ async function localCreateMany(ctx, machineId, parsed, audit) {
84
+ const drafts = draftsFromBatch(machineId, parsed);
85
+ drafts.forEach((item) => {
86
+ item.payload = stampPayload(item.payload, audit);
87
+ });
88
+ await ctx.plant.operations.upsertMany(drafts);
89
+ await drafts.reduce((chain, item) => {
90
+ return chain.then(() => {
91
+ return record(ctx.decisions, machineId, item, audit, 'create');
92
+ });
93
+ }, Promise.resolve());
94
+ return drafts;
95
+ }
96
+
97
+ /**
98
+ * Creates many operations in one request for a machine.
99
+ *
100
+ * Edge-owned machines proxy the batch body to the owning plant API.
101
+ *
102
+ * @param {object} ctx - plant, gate, decisions, owners
103
+ * @param {string} machineId - machine identifier
104
+ * @param {object} req - HTTP request
105
+ * @param {object} res - HTTP response
106
+ * @returns {Promise<void>}
107
+ */
108
+ export default async function createMany(ctx, machineId, req, res) {
109
+ if (machineMissing(ctx.plant, machineId, res)) {
110
+ return;
111
+ }
112
+ try {
113
+ const parsed = await readJson(req);
114
+ const audit = await ctx.gate.resolve(parsed);
115
+ const port = edgePort(ctx.owners, machineId);
116
+ if (port) {
117
+ const created = await port.createMany(forwardBatch(parsed, audit));
118
+ const rows = Array.isArray(created && created.items) ? created.items : [];
119
+ await rows.reduce((chain, row, index) => {
120
+ return chain.then(() => {
121
+ const source = Array.isArray(parsed.items) ? parsed.items[index] || {} : {};
122
+ return record(ctx.decisions, machineId, ownerItem(machineId, row, source), audit, 'create');
123
+ });
124
+ }, Promise.resolve());
125
+ jsonResponse(created).send(res);
126
+ return;
127
+ }
128
+ const items = await localCreateMany(ctx, machineId, parsed, audit);
129
+ jsonResponse({ items: items.map(operationJson) }).send(res);
130
+ } catch (err) {
131
+ sendFailure(ctx.gate, res, err);
132
+ }
133
+ }
@@ -77,3 +77,30 @@ export function draftFromUpdate(machineId, key, existing, parsed) {
77
77
  payload: parsed.payload
78
78
  };
79
79
  }
80
+
81
+ const BATCH_LIMIT = 50;
82
+
83
+ /**
84
+ * Builds create drafts from a batch POST body.
85
+ *
86
+ * @param {string} machineId - machine id
87
+ * @param {object} parsed - JSON body with items array
88
+ * @returns {Array<object>} operation drafts in request order
89
+ */
90
+ export function draftsFromBatch(machineId, parsed) {
91
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
92
+ throw reject('BAD_REQUEST', 'operation body must be a JSON object', 400);
93
+ }
94
+ if (!Array.isArray(parsed.items)) {
95
+ throw reject('BAD_REQUEST', 'items must be an array', 400);
96
+ }
97
+ if (parsed.items.length === 0) {
98
+ throw reject('BAD_REQUEST', 'items must not be empty', 400);
99
+ }
100
+ if (parsed.items.length > BATCH_LIMIT) {
101
+ throw reject('BAD_REQUEST', `items cannot exceed ${BATCH_LIMIT} operations`, 400);
102
+ }
103
+ return parsed.items.map((item) => {
104
+ return draftFromBody(machineId, item);
105
+ });
106
+ }
@@ -70,6 +70,9 @@ export default function operationRoute(basePath, plant, operatorOptions, decisio
70
70
  route('POST', `${basePath}/machines/:machineId/operations`, async (req, res, params) => {
71
71
  await writes.writeCreate(params.machineId, req, res);
72
72
  }),
73
+ route('POST', `${basePath}/machines/:machineId/operations/batch`, async (req, res, params) => {
74
+ await writes.writeCreateMany(params.machineId, req, res);
75
+ }),
73
76
  route('PUT', `${basePath}/machines/:machineId/operations/:key`, async (req, res, params) => {
74
77
  await writes.writeUpdate(params.machineId, decodeURIComponent(params.key), req, res);
75
78
  }),
@@ -2,6 +2,7 @@ import machineInPlant from '../../../../application/machineInPlant.js';
2
2
  import operationJson from '../json/operationJson.js';
3
3
  import { decisionRow, stampPayload } from '../operationAudit.js';
4
4
  import { draftFromBody, draftFromUpdate } from './operationDrafts.js';
5
+ import createMany from './operationBatchWrites.js';
5
6
  import httpOperations from '../../../messaging/ownership/httpOperations.js';
6
7
  import { errorResponse, jsonResponse, readBody, sendRouteError } from '@yarkivaev/simple-server';
7
8
 
@@ -169,13 +170,16 @@ async function writeDelete(ctx, machineId, key, req, res) {
169
170
  * Edge-owned machines proxy to the owning plant API without local upsert.
170
171
  *
171
172
  * @param {object} ctx - plant, gate, decisions, owners
172
- * @returns {object} writeCreate, writeUpdate, writeDelete
173
+ * @returns {object} writeCreate, writeCreateMany, writeUpdate, writeDelete
173
174
  */
174
175
  export default function operationWrites(ctx) {
175
176
  return {
176
177
  writeCreate(machineId, req, res) {
177
178
  return writeCreate(ctx, machineId, req, res);
178
179
  },
180
+ writeCreateMany(machineId, req, res) {
181
+ return createMany(ctx, machineId, req, res);
182
+ },
179
183
  writeUpdate(machineId, key, req, res) {
180
184
  return writeUpdate(ctx, machineId, key, req, res);
181
185
  },
@@ -1,4 +1,5 @@
1
1
  import machineInPlant from '../../../../application/machineInPlant.js';
2
+ import allowedSegmentTags from '../allowedSegmentTags.js';
2
3
  import segmentJson from '../json/segmentJson.js';
3
4
  import timelineOperator from '../timelineOperator.js';
4
5
  import { errorResponse, jsonResponse, readBody, route, sendRouteError } from '@yarkivaev/simple-server';
@@ -8,6 +9,18 @@ async function handleOperatorWrite(gate, parsed, write) {
8
9
  await write(audit);
9
10
  }
10
11
 
12
+ async function rejectUnknownTags(timeline, parsed, machineId, res) {
13
+ if (typeof timeline.rowAt !== 'function') {
14
+ return false;
15
+ }
16
+ const row = await timeline.rowAt(new Date(parsed.start));
17
+ if (!row || allowedSegmentTags(row.options, row.tags, parsed.tags)) {
18
+ return false;
19
+ }
20
+ errorResponse('BAD_REQUEST', `Tag is not in segment options for ${machineId}`, 400).send(res);
21
+ return true;
22
+ }
23
+
11
24
  async function respondToRequest(gate, timeline, requestId, req, res) {
12
25
  const raw = await readBody(req);
13
26
  const parsed = JSON.parse(raw);
@@ -61,6 +74,9 @@ export default function timelineRoute(basePath, plant, operatorOptions) {
61
74
  try {
62
75
  const raw = await readBody(req);
63
76
  const parsed = JSON.parse(raw);
77
+ if (await rejectUnknownTags(result.machine.timeline, parsed, params.machineId, res)) {
78
+ return;
79
+ }
64
80
  await handleOperatorWrite(gate, parsed, async (audit) => {
65
81
  await result.machine.timeline.retag(new Date(parsed.start), parsed.tags, parsed.properties, audit);
66
82
  });
@@ -81,6 +81,9 @@ export default function httpOperations(site, machineId) {
81
81
  create(body) {
82
82
  return send('POST', root, body);
83
83
  },
84
+ createMany(body) {
85
+ return send('POST', `${root}/batch`, body);
86
+ },
84
87
  update(key, body) {
85
88
  return send('PUT', `${root}/${encodeURIComponent(key)}`, body);
86
89
  },
@@ -18,6 +18,29 @@ function missing(machineId, key) {
18
18
  return new Error(`operation '${key}' not found for machine '${machineId}'`);
19
19
  }
20
20
 
21
+ /**
22
+ * Inserts or replaces one in-memory operation row.
23
+ *
24
+ * @param {object} store - shared mutable store with operations array
25
+ * @param {object} item - operation row
26
+ * @returns {{created: boolean}} whether the key was new
27
+ */
28
+ function putRow(store, item) {
29
+ const row = findRow(store, item.key);
30
+ if (row) {
31
+ row.machine = item.machine;
32
+ row.occurred_at = item.occurred_at;
33
+ row.kind = item.kind;
34
+ row.payload = item.payload;
35
+ if (item.source_updated_at) {
36
+ row.source_updated_at = item.source_updated_at;
37
+ }
38
+ return { created: false };
39
+ }
40
+ store.operations.push({ ...item });
41
+ return { created: true };
42
+ }
43
+
21
44
  function filterList(store, machineId, kind, range) {
22
45
  const from = range.from ?? null;
23
46
  const to = range.to ?? null;
@@ -84,19 +107,7 @@ export default function operationStateMemory(store) {
84
107
  }
85
108
  return {
86
109
  upsert(item) {
87
- const row = findRow(store, item.key);
88
- if (row) {
89
- row.machine = item.machine;
90
- row.occurred_at = item.occurred_at;
91
- row.kind = item.kind;
92
- row.payload = item.payload;
93
- if (item.source_updated_at) {
94
- row.source_updated_at = item.source_updated_at;
95
- }
96
- return Promise.resolve({ created: false });
97
- }
98
- store.operations.push({ ...item });
99
- return Promise.resolve({ created: true });
110
+ return Promise.resolve(putRow(store, item));
100
111
  },
101
112
  get(machineId, key) {
102
113
  const row = findScoped(store, machineId, key);
@@ -120,6 +131,11 @@ export default function operationStateMemory(store) {
120
131
  },
121
132
  latestForMachine(machineId, kind, bound) {
122
133
  return Promise.resolve(filterLatest(store, machineId, kind, bound));
134
+ },
135
+ upsertMany(items) {
136
+ return Promise.resolve(items.map((item) => {
137
+ return putRow(store, item);
138
+ }));
123
139
  }
124
140
  };
125
141
  }
@@ -20,6 +20,33 @@ async function upsertOperation(pool, item) {
20
20
  return { created: existing.rows.length === 0 };
21
21
  }
22
22
 
23
+ /**
24
+ * Inserts or updates operations in one PostgreSQL transaction.
25
+ *
26
+ * @param {object} pool - pg pool with connect()
27
+ * @param {Array<object>} items - operations in write order
28
+ * @returns {Promise<Array<{created: boolean}>>} per-row insert flags
29
+ */
30
+ async function upsertMany(pool, items) {
31
+ const client = await pool.connect();
32
+ try {
33
+ await client.query('BEGIN');
34
+ const results = [];
35
+ await items.reduce((chain, item) => {
36
+ return chain.then(async () => {
37
+ results.push(await upsertOperation(client, item));
38
+ });
39
+ }, Promise.resolve());
40
+ await client.query('COMMIT');
41
+ return results;
42
+ } catch (err) {
43
+ await client.query('ROLLBACK');
44
+ throw err;
45
+ } finally {
46
+ client.release();
47
+ }
48
+ }
49
+
23
50
  function listForMachine(pool, machineId, kind, range) {
24
51
  let sql = `SELECT machine, occurred_at, kind, key, payload
25
52
  FROM operations WHERE machine = $1 AND kind = $2`;
@@ -121,6 +148,9 @@ export default function operationStatePg(pool) {
121
148
  },
122
149
  latestForMachine(machineId, kind, bound) {
123
150
  return latestForMachine(pool, machineId, kind, bound);
151
+ },
152
+ upsertMany(items) {
153
+ return upsertMany(pool, items);
124
154
  }
125
155
  };
126
156
  }