@friggframework/admin-scripts 2.0.0-next.103 → 2.0.0-next.105
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/index.js +14 -0
- package/package.json +6 -6
- package/src/adapters/__tests__/aws-scheduler-adapter.test.js +54 -0
- package/src/adapters/__tests__/scheduler-adapter-factory.test.js +49 -0
- package/src/adapters/aws-scheduler-adapter.js +21 -9
- package/src/adapters/scheduler-adapter-factory.js +40 -0
- package/src/application/__tests__/report-runner.test.js +428 -0
- package/src/application/admin-script-context.js +12 -0
- package/src/application/report-runner.js +273 -0
- package/src/infrastructure/__tests__/report-executor-handler.test.js +314 -0
- package/src/infrastructure/__tests__/report-router.test.js +525 -0
- package/src/infrastructure/bootstrap.js +95 -15
- package/src/infrastructure/report-executor-handler.js +202 -0
- package/src/infrastructure/report-router.js +421 -0
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
const { ScriptFactory } = require('../application/script-factory');
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Admin
|
|
4
|
+
* Admin Operations Bootstrap
|
|
5
5
|
*
|
|
6
6
|
* Composition root for the admin-scripts runtime. Loads the host app's
|
|
7
|
-
* definition at Lambda runtime
|
|
8
|
-
* admin scripts
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* definition at Lambda runtime and builds two name-keyed registries — one for
|
|
8
|
+
* admin scripts, one for reports — plus the command bundles they need, so the
|
|
9
|
+
* routers and SQS workers can resolve operations by name. The registry class is
|
|
10
|
+
* shared (ScriptFactory is generic over `static Definition.name`); reports are a
|
|
11
|
+
* second instance rather than a duplicate class.
|
|
11
12
|
*
|
|
12
13
|
* Runs once per process (memoized) and never throws — a missing/unloadable app
|
|
13
|
-
* definition is logged and leaves the
|
|
14
|
+
* definition is logged and leaves the registries empty rather than crashing the
|
|
14
15
|
* Lambda cold start.
|
|
15
16
|
*/
|
|
16
17
|
let bootstrapped = false;
|
|
17
18
|
let scriptFactory = null;
|
|
18
19
|
let scriptCommands = null;
|
|
19
20
|
let integrationFactory = null;
|
|
21
|
+
let reportFactory = null;
|
|
22
|
+
let reportCommands = null;
|
|
23
|
+
let reportFriggCommands = null;
|
|
20
24
|
|
|
21
25
|
function registerScripts(factory, scriptClasses) {
|
|
22
26
|
for (const ScriptClass of scriptClasses || []) {
|
|
@@ -28,6 +32,24 @@ function registerScripts(factory, scriptClasses) {
|
|
|
28
32
|
}
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
// A report whose name collides with a registered script is skipped: scripts and
|
|
36
|
+
// reports share ScriptSchedule.scriptName (@unique), so names must not clash.
|
|
37
|
+
function registerReports(factory, scriptRegistry, reportClasses) {
|
|
38
|
+
for (const ReportClass of reportClasses || []) {
|
|
39
|
+
const name = ReportClass?.Definition?.name;
|
|
40
|
+
if (!name) continue;
|
|
41
|
+
if (scriptRegistry.has(name)) {
|
|
42
|
+
console.error(
|
|
43
|
+
`[admin-scripts] bootstrap: report "${name}" collides with a registered script name; skipping.`
|
|
44
|
+
);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!factory.has(name)) {
|
|
48
|
+
factory.register(ReportClass);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
31
53
|
/**
|
|
32
54
|
* Build an integration factory backed by the framework's runtime hydration.
|
|
33
55
|
* Scripts call `context.instantiate(integrationId)` which delegates here.
|
|
@@ -46,7 +68,7 @@ function createIntegrationFactory() {
|
|
|
46
68
|
}
|
|
47
69
|
|
|
48
70
|
/**
|
|
49
|
-
* Build the command bundle injected into every AdminScriptContext.
|
|
71
|
+
* Build the command bundle injected into every AdminScriptContext. Operations
|
|
50
72
|
* interact with the database only through these Frigg commands — never through
|
|
51
73
|
* repositories directly. The require is lazy so the package keeps no load-time
|
|
52
74
|
* coupling to core's command/repository modules.
|
|
@@ -75,27 +97,62 @@ function buildScriptCommands() {
|
|
|
75
97
|
};
|
|
76
98
|
}
|
|
77
99
|
|
|
100
|
+
function buildReportFriggCommands() {
|
|
101
|
+
const {
|
|
102
|
+
createIntegrationMappingCommands,
|
|
103
|
+
} = require('@friggframework/core/application/commands/integration-mapping-commands');
|
|
104
|
+
const {
|
|
105
|
+
createUsageCommands,
|
|
106
|
+
} = require('@friggframework/core/application/commands/usage-commands');
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
...buildScriptCommands(),
|
|
110
|
+
integrationMappings: createIntegrationMappingCommands(),
|
|
111
|
+
usage: createUsageCommands(),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
78
115
|
/**
|
|
79
|
-
* @returns {{ scriptFactory: ScriptFactory, scriptCommands: object, integrationFactory: object }}
|
|
116
|
+
* @returns {{ scriptFactory: ScriptFactory, scriptCommands: object, integrationFactory: object, reportFactory: ScriptFactory, reportCommands: object, reportFriggCommands: object }}
|
|
80
117
|
*/
|
|
81
118
|
function bootstrapAdminScripts() {
|
|
82
119
|
if (bootstrapped) {
|
|
83
|
-
return {
|
|
120
|
+
return {
|
|
121
|
+
scriptFactory,
|
|
122
|
+
scriptCommands,
|
|
123
|
+
integrationFactory,
|
|
124
|
+
reportFactory,
|
|
125
|
+
reportCommands,
|
|
126
|
+
reportFriggCommands,
|
|
127
|
+
};
|
|
84
128
|
}
|
|
85
129
|
bootstrapped = true;
|
|
86
130
|
|
|
87
|
-
// Create the
|
|
88
|
-
//
|
|
131
|
+
// Create the registries up front so consumers always get (possibly empty)
|
|
132
|
+
// factories even when the app definition can't be loaded — mirrors the
|
|
89
133
|
// never-throw contract above.
|
|
90
134
|
scriptFactory = new ScriptFactory();
|
|
135
|
+
reportFactory = new ScriptFactory();
|
|
91
136
|
|
|
92
137
|
try {
|
|
93
138
|
const {
|
|
94
139
|
loadAppDefinition,
|
|
95
140
|
} = require('@friggframework/core/handlers/app-definition-loader');
|
|
96
|
-
const {
|
|
141
|
+
const {
|
|
142
|
+
adminScripts = [],
|
|
143
|
+
reports = [],
|
|
144
|
+
admin = {},
|
|
145
|
+
} = loadAppDefinition();
|
|
97
146
|
|
|
98
147
|
registerScripts(scriptFactory, adminScripts);
|
|
148
|
+
registerReports(reportFactory, scriptFactory, reports);
|
|
149
|
+
|
|
150
|
+
if (admin.includeBuiltinReports) {
|
|
151
|
+
const {
|
|
152
|
+
BUILTIN_REPORTS,
|
|
153
|
+
} = require('@friggframework/core/reporting/builtin-reports');
|
|
154
|
+
registerReports(reportFactory, scriptFactory, BUILTIN_REPORTS);
|
|
155
|
+
}
|
|
99
156
|
} catch (error) {
|
|
100
157
|
console.error(
|
|
101
158
|
'[admin-scripts] bootstrap: could not load app definition:',
|
|
@@ -103,8 +160,8 @@ function bootstrapAdminScripts() {
|
|
|
103
160
|
);
|
|
104
161
|
}
|
|
105
162
|
|
|
106
|
-
// Built in
|
|
107
|
-
// registration (and vice versa) —
|
|
163
|
+
// Built in their own try so a command-layer failure never blocks operation
|
|
164
|
+
// registration (and vice versa) — all honor the never-throw contract.
|
|
108
165
|
try {
|
|
109
166
|
scriptCommands = buildScriptCommands();
|
|
110
167
|
} catch (error) {
|
|
@@ -114,8 +171,28 @@ function bootstrapAdminScripts() {
|
|
|
114
171
|
);
|
|
115
172
|
}
|
|
116
173
|
|
|
174
|
+
try {
|
|
175
|
+
reportFriggCommands = buildReportFriggCommands();
|
|
176
|
+
const {
|
|
177
|
+
createReportCommands,
|
|
178
|
+
} = require('@friggframework/core/application/commands/report-commands');
|
|
179
|
+
reportCommands = createReportCommands();
|
|
180
|
+
} catch (error) {
|
|
181
|
+
console.error(
|
|
182
|
+
'[admin-scripts] bootstrap: could not build report commands:',
|
|
183
|
+
error.message
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
117
187
|
integrationFactory = createIntegrationFactory();
|
|
118
|
-
return {
|
|
188
|
+
return {
|
|
189
|
+
scriptFactory,
|
|
190
|
+
scriptCommands,
|
|
191
|
+
integrationFactory,
|
|
192
|
+
reportFactory,
|
|
193
|
+
reportCommands,
|
|
194
|
+
reportFriggCommands,
|
|
195
|
+
};
|
|
119
196
|
}
|
|
120
197
|
|
|
121
198
|
/** Test-only: reset memoized bootstrap state. */
|
|
@@ -124,6 +201,9 @@ function _resetBootstrapForTests() {
|
|
|
124
201
|
scriptFactory = null;
|
|
125
202
|
scriptCommands = null;
|
|
126
203
|
integrationFactory = null;
|
|
204
|
+
reportFactory = null;
|
|
205
|
+
reportCommands = null;
|
|
206
|
+
reportFriggCommands = null;
|
|
127
207
|
}
|
|
128
208
|
|
|
129
209
|
module.exports = {
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
const { createReportRunner } = require('../application/report-runner');
|
|
2
|
+
const {
|
|
3
|
+
createReportCommands,
|
|
4
|
+
} = require('@friggframework/core/application/commands/report-commands');
|
|
5
|
+
const { QueuerUtil } = require('@friggframework/core/queues');
|
|
6
|
+
const { bootstrapAdminScripts } = require('./bootstrap');
|
|
7
|
+
|
|
8
|
+
// Resume state travels in the message (params.__resume); the execution record
|
|
9
|
+
// stays RUNNING across hops so the same execution resumes.
|
|
10
|
+
async function requeueContinuation(message, result) {
|
|
11
|
+
const queueUrl = process.env.REPORT_QUEUE_URL;
|
|
12
|
+
if (!queueUrl) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
'Report yielded a continuation but REPORT_QUEUE_URL is not set; cannot resume'
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
await QueuerUtil.send(
|
|
19
|
+
{
|
|
20
|
+
reportName: message.reportName,
|
|
21
|
+
executionId: result.executionId,
|
|
22
|
+
mode: message.mode || 'recorded',
|
|
23
|
+
...(message.seriesName && { seriesName: message.seriesName }),
|
|
24
|
+
trigger: 'QUEUE',
|
|
25
|
+
params: { ...(message.params || {}), __resume: result.continuation },
|
|
26
|
+
resumeAt: new Date().toISOString(),
|
|
27
|
+
},
|
|
28
|
+
queueUrl
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function runMessage(
|
|
33
|
+
message,
|
|
34
|
+
{ reportFactory, reportCommands, reportFriggCommands, integrationFactory },
|
|
35
|
+
lambdaContext
|
|
36
|
+
) {
|
|
37
|
+
const {
|
|
38
|
+
reportName,
|
|
39
|
+
executionId,
|
|
40
|
+
mode,
|
|
41
|
+
seriesName,
|
|
42
|
+
trigger,
|
|
43
|
+
params,
|
|
44
|
+
parentExecutionId,
|
|
45
|
+
} = message;
|
|
46
|
+
|
|
47
|
+
if (!reportName) {
|
|
48
|
+
throw new Error('Invalid message: missing reportName');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log(
|
|
52
|
+
`Processing report: ${reportName}${
|
|
53
|
+
executionId ? `, executionId: ${executionId}` : ''
|
|
54
|
+
}`
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const runner = createReportRunner({
|
|
58
|
+
reportFactory,
|
|
59
|
+
reportCommands,
|
|
60
|
+
friggCommands: reportFriggCommands,
|
|
61
|
+
integrationFactory,
|
|
62
|
+
});
|
|
63
|
+
const result = await runner.execute(reportName, params, {
|
|
64
|
+
mode: mode || 'recorded',
|
|
65
|
+
trigger: trigger || 'QUEUE',
|
|
66
|
+
// Scheduled first runs carry no executionId; ReportRunner creates the record.
|
|
67
|
+
...(executionId && { executionId }),
|
|
68
|
+
...(seriesName && { seriesName }),
|
|
69
|
+
...(parentExecutionId && { parentExecutionId }),
|
|
70
|
+
...(lambdaContext && { lambdaContext }),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (result.status === 'CONTINUE') {
|
|
74
|
+
// A failed re-enqueue means the execution never resumes; mark it FAILED
|
|
75
|
+
// (via the runner-created id) instead of leaving it stuck in RUNNING.
|
|
76
|
+
try {
|
|
77
|
+
await requeueContinuation(message, result);
|
|
78
|
+
} catch (requeueError) {
|
|
79
|
+
await markFailed(result.executionId, requeueError);
|
|
80
|
+
throw requeueError;
|
|
81
|
+
}
|
|
82
|
+
console.log(
|
|
83
|
+
`Report re-queued for continuation: ${reportName}, executionId: ${result.executionId}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
reportName,
|
|
89
|
+
status: result.status,
|
|
90
|
+
executionId: result.executionId,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Mark a report execution FAILED when the worker itself throws, so the record
|
|
95
|
+
// doesn't stay stuck in a non-terminal state.
|
|
96
|
+
async function markFailed(executionId, error) {
|
|
97
|
+
if (!executionId) return;
|
|
98
|
+
try {
|
|
99
|
+
const commands = createReportCommands();
|
|
100
|
+
await commands.completeExecution(executionId, {
|
|
101
|
+
state: 'FAILED',
|
|
102
|
+
error: {
|
|
103
|
+
name: error.name,
|
|
104
|
+
message: error.message,
|
|
105
|
+
stack: error.stack,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
} catch (updateError) {
|
|
109
|
+
console.error(
|
|
110
|
+
`Failed to update report execution ${executionId} state:`,
|
|
111
|
+
updateError
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// EventBridge Scheduler direct invoke: the event itself is the message (no `Records` wrapper).
|
|
117
|
+
async function handleScheduledInvoke(event, deps, lambdaContext) {
|
|
118
|
+
try {
|
|
119
|
+
const result = await runMessage(event, deps, lambdaContext);
|
|
120
|
+
console.log(
|
|
121
|
+
`Report completed: ${result.reportName}, status: ${result.status}`
|
|
122
|
+
);
|
|
123
|
+
return {
|
|
124
|
+
statusCode: 200,
|
|
125
|
+
body: JSON.stringify({ processed: 1, results: [result] }),
|
|
126
|
+
};
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error('Unexpected error processing scheduled invoke:', error);
|
|
129
|
+
await markFailed(event.executionId, error);
|
|
130
|
+
return {
|
|
131
|
+
statusCode: 200,
|
|
132
|
+
body: JSON.stringify({
|
|
133
|
+
processed: 1,
|
|
134
|
+
results: [
|
|
135
|
+
{
|
|
136
|
+
reportName: event.reportName || 'unknown',
|
|
137
|
+
status: 'FAILED',
|
|
138
|
+
error: error.message,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// SQS batch: each record body is a JSON message. Failures are isolated per
|
|
147
|
+
// record so one bad message doesn't drop the rest of the batch.
|
|
148
|
+
async function handleSqsBatch(event, deps, lambdaContext) {
|
|
149
|
+
const results = [];
|
|
150
|
+
for (const record of event.Records) {
|
|
151
|
+
let message = {};
|
|
152
|
+
try {
|
|
153
|
+
message = JSON.parse(record.body);
|
|
154
|
+
const result = await runMessage(message, deps, lambdaContext);
|
|
155
|
+
console.log(
|
|
156
|
+
`Report completed: ${result.reportName}, status: ${result.status}`
|
|
157
|
+
);
|
|
158
|
+
results.push(result);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// Report execution errors are handled by ReportRunner; only
|
|
161
|
+
// unexpected failures (parse, runner construction) reach here.
|
|
162
|
+
console.error('Unexpected error processing record:', error);
|
|
163
|
+
await markFailed(message.executionId, error);
|
|
164
|
+
results.push({
|
|
165
|
+
reportName: message.reportName || 'unknown',
|
|
166
|
+
status: 'FAILED',
|
|
167
|
+
error: error.message,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
statusCode: 200,
|
|
174
|
+
body: JSON.stringify({ processed: results.length, results }),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Report Executor Lambda handler. Two invocation shapes:
|
|
180
|
+
* - SQS: `event.Records[]`, each body a JSON execution message.
|
|
181
|
+
* - EventBridge Scheduler direct invoke: the event itself is the message, no `Records`.
|
|
182
|
+
*/
|
|
183
|
+
async function handler(event, context) {
|
|
184
|
+
const {
|
|
185
|
+
reportFactory,
|
|
186
|
+
reportCommands,
|
|
187
|
+
reportFriggCommands,
|
|
188
|
+
integrationFactory,
|
|
189
|
+
} = bootstrapAdminScripts();
|
|
190
|
+
const deps = {
|
|
191
|
+
reportFactory,
|
|
192
|
+
reportCommands,
|
|
193
|
+
reportFriggCommands,
|
|
194
|
+
integrationFactory,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return event.Records
|
|
198
|
+
? handleSqsBatch(event, deps, context)
|
|
199
|
+
: handleScheduledInvoke(event, deps, context);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = { handler };
|