@yarkivaev/scada 2.3.51 → 2.3.53
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 +1 -1
- package/src/application/plantOperations.js +2 -2
- package/src/domain/operation/operations.js +33 -7
- package/src/infrastructure/client/machineOperationsClient.js +8 -0
- package/src/infrastructure/http/plant/routes/operationBatchWrites.js +133 -0
- package/src/infrastructure/http/plant/routes/operationDrafts.js +27 -0
- package/src/infrastructure/http/plant/routes/operationRoute.js +36 -8
- package/src/infrastructure/http/plant/routes/operationWrites.js +5 -1
- package/src/infrastructure/messaging/ownership/httpOperations.js +61 -34
- package/src/infrastructure/persistence/memory/operations.js +29 -13
- package/src/infrastructure/persistence/pg/operations.js +30 -0
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, 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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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,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
|
+
}
|
|
@@ -2,7 +2,8 @@ import machineInPlant from '../../../../application/machineInPlant.js';
|
|
|
2
2
|
import operationJson from '../json/operationJson.js';
|
|
3
3
|
import timelineOperator from '../timelineOperator.js';
|
|
4
4
|
import operationWrites from './operationWrites.js';
|
|
5
|
-
import
|
|
5
|
+
import httpOperations from '../../../messaging/ownership/httpOperations.js';
|
|
6
|
+
import { jsonResponse, route, sendRouteError } from '@yarkivaev/simple-server';
|
|
6
7
|
|
|
7
8
|
function parseRange(query) {
|
|
8
9
|
const range = {};
|
|
@@ -29,11 +30,31 @@ function resolveKinds(query) {
|
|
|
29
30
|
return undefined;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
function edgePort(owners, machineId) {
|
|
34
|
+
if (!owners || typeof owners.resolve !== 'function') {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const owner = owners.resolve(machineId);
|
|
38
|
+
if (!owner || owner.kind !== 'edge') {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
return httpOperations(owner, machineId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function listLocal(plant, machineId, query) {
|
|
45
|
+
const rows = await plant.operations.listForMachine(
|
|
46
|
+
machineId,
|
|
47
|
+
resolveKinds(query),
|
|
48
|
+
parseRange(query)
|
|
49
|
+
);
|
|
50
|
+
return { items: rows.map(operationJson) };
|
|
51
|
+
}
|
|
52
|
+
|
|
32
53
|
/**
|
|
33
54
|
* Operations REST routes for machine-scoped reads and writes.
|
|
34
55
|
*
|
|
35
56
|
* Writes resolve operator via timelineOperator and stamp payload.operator.
|
|
36
|
-
* Edge-owned machines proxy create/update/delete to the owning plant API
|
|
57
|
+
* Edge-owned machines proxy list/create/update/delete to the owning plant API
|
|
37
58
|
* (no local upsert or decision insert). Optional owners registry mirrors timeline.
|
|
38
59
|
*
|
|
39
60
|
* @param {string} basePath - base URL path
|
|
@@ -60,16 +81,23 @@ export default function operationRoute(basePath, plant, operatorOptions, decisio
|
|
|
60
81
|
jsonResponse({ items: [] }).send(res);
|
|
61
82
|
return;
|
|
62
83
|
}
|
|
63
|
-
|
|
64
|
-
params.machineId
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
84
|
+
try {
|
|
85
|
+
const port = edgePort(owners, params.machineId);
|
|
86
|
+
const listed = port
|
|
87
|
+
? await port.list(query)
|
|
88
|
+
: await listLocal(plant, params.machineId, query);
|
|
89
|
+
const items = Array.isArray(listed && listed.items) ? listed.items : [];
|
|
90
|
+
jsonResponse({ items }).send(res);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
sendRouteError(res, err);
|
|
93
|
+
}
|
|
69
94
|
}),
|
|
70
95
|
route('POST', `${basePath}/machines/:machineId/operations`, async (req, res, params) => {
|
|
71
96
|
await writes.writeCreate(params.machineId, req, res);
|
|
72
97
|
}),
|
|
98
|
+
route('POST', `${basePath}/machines/:machineId/operations/batch`, async (req, res, params) => {
|
|
99
|
+
await writes.writeCreateMany(params.machineId, req, res);
|
|
100
|
+
}),
|
|
73
101
|
route('PUT', `${basePath}/machines/:machineId/operations/:key`, async (req, res, params) => {
|
|
74
102
|
await writes.writeUpdate(params.machineId, decodeURIComponent(params.key), req, res);
|
|
75
103
|
}),
|
|
@@ -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
|
},
|
|
@@ -2,6 +2,18 @@ function trimBase(url) {
|
|
|
2
2
|
return String(url).replace(/\/$/u, '');
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
function listPath(root, query) {
|
|
6
|
+
const params = new URLSearchParams();
|
|
7
|
+
Object.keys(query || {}).forEach((name) => {
|
|
8
|
+
const value = query[name];
|
|
9
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
10
|
+
params.set(name, String(value));
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
const suffix = params.toString();
|
|
14
|
+
return suffix.length > 0 ? `${root}?${suffix}` : root;
|
|
15
|
+
}
|
|
16
|
+
|
|
5
17
|
function authHeaders(token) {
|
|
6
18
|
const headers = { 'Content-Type': 'application/json' };
|
|
7
19
|
if (token) {
|
|
@@ -33,62 +45,77 @@ async function readJson(res) {
|
|
|
33
45
|
return JSON.parse(text);
|
|
34
46
|
}
|
|
35
47
|
|
|
48
|
+
function ownerContext(site, machineId) {
|
|
49
|
+
return {
|
|
50
|
+
base: trimBase(site.baseUrl),
|
|
51
|
+
fetcher: site.fetch || fetch,
|
|
52
|
+
token: site.token,
|
|
53
|
+
machineId
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function send(ctx, method, path, body) {
|
|
58
|
+
const url = `${ctx.base}${path}`;
|
|
59
|
+
let res;
|
|
60
|
+
try {
|
|
61
|
+
res = await ctx.fetcher(url, {
|
|
62
|
+
method,
|
|
63
|
+
headers: authHeaders(ctx.token),
|
|
64
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
65
|
+
});
|
|
66
|
+
} catch (cause) {
|
|
67
|
+
throw ownerError(
|
|
68
|
+
`owner operations unreachable for ${ctx.machineId}: ${cause.message}`,
|
|
69
|
+
'SERVICE_UNAVAILABLE',
|
|
70
|
+
503,
|
|
71
|
+
cause
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
throw ownerError(
|
|
76
|
+
`owner operations ${method} ${path} for ${ctx.machineId} failed: ${res.status} ${await readError(res)}`,
|
|
77
|
+
'BAD_GATEWAY',
|
|
78
|
+
502
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return readJson(res);
|
|
82
|
+
}
|
|
83
|
+
|
|
36
84
|
/**
|
|
37
|
-
* HTTP operations
|
|
85
|
+
* HTTP operations port that proxies list/create/update/delete to an edge plant API.
|
|
38
86
|
*
|
|
39
87
|
* Matches the owning-edge contract: central never upserts locally for edge machines.
|
|
40
88
|
* Failures surface as route errors (503 unreachable, 502 non-ok).
|
|
41
89
|
*
|
|
42
90
|
* @param {object} site - edge owner with baseUrl and optional token/fetch
|
|
43
91
|
* @param {string} machineId - machine identifier
|
|
44
|
-
* @returns {object} operations
|
|
92
|
+
* @returns {object} operations port
|
|
45
93
|
*
|
|
46
94
|
* @example
|
|
47
95
|
* const port = httpOperations({ baseUrl: 'http://edge/api/v1' }, 'm2');
|
|
48
96
|
* await port.create({ kind: 'load', payload: {}, operatorId: 2 });
|
|
49
97
|
*/
|
|
50
98
|
export default function httpOperations(site, machineId) {
|
|
51
|
-
const
|
|
52
|
-
const fetcher = site.fetch || fetch;
|
|
53
|
-
async function send(method, path, body) {
|
|
54
|
-
const url = `${base}${path}`;
|
|
55
|
-
let res;
|
|
56
|
-
try {
|
|
57
|
-
res = await fetcher(url, {
|
|
58
|
-
method,
|
|
59
|
-
headers: authHeaders(site.token),
|
|
60
|
-
body: body === undefined ? undefined : JSON.stringify(body)
|
|
61
|
-
});
|
|
62
|
-
} catch (cause) {
|
|
63
|
-
throw ownerError(
|
|
64
|
-
`owner operations unreachable for ${machineId}: ${cause.message}`,
|
|
65
|
-
'SERVICE_UNAVAILABLE',
|
|
66
|
-
503,
|
|
67
|
-
cause
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
if (!res.ok) {
|
|
71
|
-
throw ownerError(
|
|
72
|
-
`owner operations ${method} ${path} for ${machineId} failed: ${res.status} ${await readError(res)}`,
|
|
73
|
-
'BAD_GATEWAY',
|
|
74
|
-
502
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
return readJson(res);
|
|
78
|
-
}
|
|
99
|
+
const ctx = ownerContext(site, machineId);
|
|
79
100
|
const root = `/machines/${encodeURIComponent(machineId)}/operations`;
|
|
80
101
|
return Object.freeze({
|
|
102
|
+
list(query) {
|
|
103
|
+
return send(ctx, 'GET', listPath(root, query));
|
|
104
|
+
},
|
|
81
105
|
create(body) {
|
|
82
|
-
return send('POST', root, body);
|
|
106
|
+
return send(ctx, 'POST', root, body);
|
|
107
|
+
},
|
|
108
|
+
createMany(body) {
|
|
109
|
+
return send(ctx, 'POST', `${root}/batch`, body);
|
|
83
110
|
},
|
|
84
111
|
update(key, body) {
|
|
85
|
-
return send('PUT', `${root}/${encodeURIComponent(key)}`, body);
|
|
112
|
+
return send(ctx, 'PUT', `${root}/${encodeURIComponent(key)}`, body);
|
|
86
113
|
},
|
|
87
114
|
remove(key, body) {
|
|
88
|
-
return send('DELETE', `${root}/${encodeURIComponent(key)}`, body);
|
|
115
|
+
return send(ctx, 'DELETE', `${root}/${encodeURIComponent(key)}`, body);
|
|
89
116
|
},
|
|
90
117
|
decisions(key) {
|
|
91
|
-
return send('GET', `${root}/${encodeURIComponent(key)}/decisions`).then((body) => {
|
|
118
|
+
return send(ctx, 'GET', `${root}/${encodeURIComponent(key)}/decisions`).then((body) => {
|
|
92
119
|
return Array.isArray(body && body.items) ? body.items : [];
|
|
93
120
|
});
|
|
94
121
|
}
|
|
@@ -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
|
-
|
|
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
|
}
|