@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.
@@ -0,0 +1,273 @@
1
+ const { createAdminScriptContext } = require('./admin-script-context');
2
+ const { validateParams } = require('./validate-script-input');
3
+
4
+ const ARTIFACT_CONTENT_TYPES = {
5
+ csv: 'text/csv',
6
+ json: 'application/json',
7
+ pdf: 'application/pdf',
8
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
9
+ html: 'text/html',
10
+ txt: 'text/plain',
11
+ xml: 'application/xml',
12
+ zip: 'application/zip',
13
+ };
14
+
15
+ // The run mode decides persistence: live computes inline and records nothing,
16
+ // recorded persists an execution record, snapshot additionally tags the run
17
+ // into a named series. Reports read only through the injected frigg command
18
+ // bundle (exposed as context.commands).
19
+ class ReportRunner {
20
+ constructor(params = {}) {
21
+ if (!params.reportFactory) {
22
+ throw new Error('ReportRunner requires a reportFactory');
23
+ }
24
+ this.reportFactory = params.reportFactory;
25
+ this.reportCommands = params.reportCommands || null;
26
+ this.friggCommands = params.friggCommands || null;
27
+ this.integrationFactory = params.integrationFactory || null;
28
+ this.artifactRepository = params.artifactRepository || null;
29
+ }
30
+
31
+ _getArtifactRepository() {
32
+ if (!this.artifactRepository) {
33
+ const {
34
+ createArtifactRepository,
35
+ } = require('@friggframework/core/artifacts/repositories/artifact-repository-factory');
36
+ this.artifactRepository = createArtifactRepository();
37
+ }
38
+ return this.artifactRepository;
39
+ }
40
+
41
+ async execute(reportName, params = {}, options = {}) {
42
+ const ReportClass = this.reportFactory.get(reportName);
43
+ const definition = ReportClass.Definition;
44
+
45
+ const runModes =
46
+ Array.isArray(definition.runModes) && definition.runModes.length
47
+ ? definition.runModes
48
+ : ['live'];
49
+ const mode = options.mode || runModes[0];
50
+ if (!runModes.includes(mode)) {
51
+ const error = new Error(
52
+ `Report "${reportName}" does not support mode "${mode}". Allowed: ${runModes.join(
53
+ ', '
54
+ )}`
55
+ );
56
+ error.code = 'INVALID_MODE';
57
+ throw error;
58
+ }
59
+
60
+ const validation = validateParams(definition, params);
61
+ if (!validation.valid) {
62
+ const error = new Error(
63
+ `Invalid input: ${validation.errors.join(', ')}`
64
+ );
65
+ error.code = 'INVALID_INPUT';
66
+ throw error;
67
+ }
68
+
69
+ const format = definition.output?.format || 'json';
70
+
71
+ if (mode === 'live') {
72
+ // Non-JSON can't be returned inline as a response body; it needs an
73
+ // artifact, so it must run recorded/snapshot.
74
+ if (format !== 'json') {
75
+ const error = new Error(
76
+ `Report "${reportName}" output format "${format}" cannot be returned inline; run it in recorded or snapshot mode`
77
+ );
78
+ error.code = 'ARTIFACT_STORAGE_UNAVAILABLE';
79
+ throw error;
80
+ }
81
+ return this._runLive(reportName, params);
82
+ }
83
+
84
+ return this._runRecorded(reportName, definition, params, mode, options);
85
+ }
86
+
87
+ async _runLive(reportName, params) {
88
+ const startTime = new Date();
89
+ // executionId null: live persists nothing, so record-dependent logging
90
+ // and chaining are intentionally inert.
91
+ const context = createAdminScriptContext({
92
+ executionId: null,
93
+ integrationFactory: this.integrationFactory,
94
+ commands: this.friggCommands,
95
+ });
96
+
97
+ const report = this.reportFactory.createInstance(reportName, {
98
+ context,
99
+ executionId: null,
100
+ integrationFactory: this.integrationFactory,
101
+ });
102
+
103
+ const output = await report.execute(context.commands, params, context);
104
+
105
+ return {
106
+ status: 'COMPLETED',
107
+ reportName,
108
+ mode: 'live',
109
+ output,
110
+ metrics: { durationMs: new Date() - startTime },
111
+ };
112
+ }
113
+
114
+ // Completion is written OUTSIDE the try on success so a persistence failure
115
+ // is never misreported as a report failure.
116
+ async _runRecorded(reportName, definition, params, mode, options = {}) {
117
+ let executionId = options.executionId;
118
+
119
+ if (!executionId) {
120
+ const execution = await this.reportCommands.createExecution({
121
+ reportName,
122
+ reportVersion: definition.version,
123
+ trigger: options.trigger || 'MANUAL',
124
+ mode,
125
+ input: params,
126
+ audit: options.audit,
127
+ seriesName:
128
+ mode === 'snapshot'
129
+ ? options.seriesName || reportName
130
+ : undefined,
131
+ parentExecutionId: options.parentExecutionId,
132
+ });
133
+ if (execution.error) {
134
+ throw new Error(
135
+ execution.reason ||
136
+ 'Failed to create report execution record'
137
+ );
138
+ }
139
+ executionId = execution.id;
140
+ }
141
+
142
+ const startTime = new Date();
143
+ const context = createAdminScriptContext({
144
+ executionId,
145
+ integrationFactory: this.integrationFactory,
146
+ commands: this.friggCommands,
147
+ lambdaContext: options.lambdaContext,
148
+ });
149
+
150
+ const format = definition.output?.format || 'json';
151
+ let output;
152
+ let artifact = null;
153
+ let summary;
154
+ try {
155
+ await this.reportCommands.updateExecutionState(
156
+ executionId,
157
+ 'RUNNING'
158
+ );
159
+
160
+ const report = this.reportFactory.createInstance(reportName, {
161
+ context,
162
+ executionId,
163
+ integrationFactory: this.integrationFactory,
164
+ });
165
+
166
+ output = await report.execute(context.commands, params, context);
167
+
168
+ // Store non-JSON output as an artifact inside the try so a put
169
+ // failure marks the run FAILED (a continuation yield has none yet).
170
+ if (format !== 'json' && !(output && output.__continuation)) {
171
+ const stamp = new Date()
172
+ .toISOString()
173
+ .replace(/[:.]/g, '-');
174
+ const fileType = output.fileType || format;
175
+ const key = `reports/${executionId}/${reportName}-${stamp}.${fileType}`;
176
+ const contentType =
177
+ ARTIFACT_CONTENT_TYPES[fileType] ||
178
+ 'application/octet-stream';
179
+ artifact = await this._getArtifactRepository().put(
180
+ key,
181
+ output.file,
182
+ contentType
183
+ );
184
+ summary = output.summary;
185
+ }
186
+ } catch (error) {
187
+ const durationMs = new Date() - startTime;
188
+ await this.reportCommands.completeExecution(executionId, {
189
+ state: 'FAILED',
190
+ error: {
191
+ name: error.name,
192
+ message: error.message,
193
+ stack: error.stack,
194
+ },
195
+ metrics: {
196
+ startTime: startTime.toISOString(),
197
+ endTime: new Date().toISOString(),
198
+ durationMs,
199
+ },
200
+ logs: context.getLogs(),
201
+ });
202
+
203
+ return {
204
+ executionId,
205
+ status: 'FAILED',
206
+ reportName,
207
+ mode,
208
+ error: { name: error.name, message: error.message },
209
+ metrics: { durationMs },
210
+ };
211
+ }
212
+
213
+ // Opt-in self-requeue: a report yields by returning a truthy
214
+ // `__continuation` (its resume state) instead of a final result. The
215
+ // execution stays RUNNING and the executor re-enqueues the SAME id, so
216
+ // the report resumes from the marker on the next invocation.
217
+ if (output && output.__continuation) {
218
+ const resumeState = output.__continuation;
219
+ if (typeof this.reportCommands.appendExecutionLog === 'function') {
220
+ await this.reportCommands.appendExecutionLog(executionId, {
221
+ level: 'info',
222
+ message: 'report yielded a continuation; re-queueing',
223
+ data: { resumeAt: new Date().toISOString() },
224
+ timestamp: new Date().toISOString(),
225
+ });
226
+ }
227
+ return {
228
+ executionId,
229
+ status: 'CONTINUE',
230
+ reportName,
231
+ mode,
232
+ continuation: resumeState,
233
+ metrics: { durationMs: new Date() - startTime },
234
+ };
235
+ }
236
+
237
+ const durationMs = new Date() - startTime;
238
+ const isArtifact = format !== 'json';
239
+ const result = {
240
+ executionId,
241
+ status: 'COMPLETED',
242
+ reportName,
243
+ mode,
244
+ ...(isArtifact ? { summary, artifact } : { output }),
245
+ metrics: { durationMs },
246
+ };
247
+
248
+ const completion = await this.reportCommands.completeExecution(
249
+ executionId,
250
+ {
251
+ state: 'COMPLETED',
252
+ ...(isArtifact ? { summary, artifact } : { output }),
253
+ metrics: {
254
+ startTime: startTime.toISOString(),
255
+ endTime: new Date().toISOString(),
256
+ durationMs,
257
+ },
258
+ logs: context.getLogs(),
259
+ }
260
+ );
261
+ if (completion?.error) {
262
+ result.stateUpdateFailed = true;
263
+ }
264
+
265
+ return result;
266
+ }
267
+ }
268
+
269
+ function createReportRunner(params = {}) {
270
+ return new ReportRunner(params);
271
+ }
272
+
273
+ module.exports = { ReportRunner, createReportRunner };
@@ -0,0 +1,314 @@
1
+ jest.mock('../bootstrap');
2
+ jest.mock('../../application/report-runner');
3
+ jest.mock('@friggframework/core/application/commands/report-commands');
4
+ jest.mock('@friggframework/core/queues', () => ({
5
+ QueuerUtil: { send: jest.fn().mockResolvedValue({}) },
6
+ }));
7
+
8
+ const { bootstrapAdminScripts } = require('../bootstrap');
9
+ const { createReportRunner } = require('../../application/report-runner');
10
+ const {
11
+ createReportCommands,
12
+ } = require('@friggframework/core/application/commands/report-commands');
13
+ const { QueuerUtil } = require('@friggframework/core/queues');
14
+ const { handler } = require('../report-executor-handler');
15
+
16
+ describe('Report Executor Handler', () => {
17
+ let mockReportFactory;
18
+ let mockIntegrationFactory;
19
+ let mockReportFriggCommands;
20
+ let mockRunner;
21
+ let mockCommands;
22
+
23
+ beforeEach(() => {
24
+ mockReportFactory = { id: 'report-factory' };
25
+ mockIntegrationFactory = { id: 'integration-factory' };
26
+ mockReportFriggCommands = { id: 'report-frigg-commands' };
27
+ mockRunner = { execute: jest.fn() };
28
+ mockCommands = {
29
+ completeExecution: jest.fn().mockResolvedValue({}),
30
+ };
31
+
32
+ bootstrapAdminScripts.mockReturnValue({
33
+ reportFactory: mockReportFactory,
34
+ reportCommands: { id: 'report-commands' },
35
+ reportFriggCommands: mockReportFriggCommands,
36
+ integrationFactory: mockIntegrationFactory,
37
+ });
38
+ createReportRunner.mockReturnValue(mockRunner);
39
+ createReportCommands.mockReturnValue(mockCommands);
40
+
41
+ jest.spyOn(console, 'log').mockImplementation(() => {});
42
+ jest.spyOn(console, 'error').mockImplementation(() => {});
43
+ });
44
+
45
+ afterEach(() => {
46
+ console.log.mockRestore();
47
+ console.error.mockRestore();
48
+ jest.clearAllMocks();
49
+ });
50
+
51
+ describe('EventBridge Scheduler direct invoke (no Records)', () => {
52
+ it('injects the bootstrapped factory into the runner and reports the result', async () => {
53
+ mockRunner.execute.mockResolvedValue({
54
+ status: 'COMPLETED',
55
+ executionId: 'exec-1',
56
+ });
57
+
58
+ const response = await handler({
59
+ reportName: 'my-report',
60
+ trigger: 'SCHEDULED',
61
+ mode: 'snapshot',
62
+ seriesName: 'daily',
63
+ params: { foo: 'bar' },
64
+ });
65
+
66
+ expect(createReportRunner).toHaveBeenCalledWith({
67
+ reportFactory: mockReportFactory,
68
+ reportCommands: { id: 'report-commands' },
69
+ friggCommands: mockReportFriggCommands,
70
+ integrationFactory: mockIntegrationFactory,
71
+ });
72
+ expect(mockRunner.execute).toHaveBeenCalledWith(
73
+ 'my-report',
74
+ { foo: 'bar' },
75
+ expect.objectContaining({
76
+ trigger: 'SCHEDULED',
77
+ mode: 'snapshot',
78
+ seriesName: 'daily',
79
+ })
80
+ );
81
+
82
+ expect(response.statusCode).toBe(200);
83
+ const body = JSON.parse(response.body);
84
+ expect(body.processed).toBe(1);
85
+ expect(body.results[0]).toEqual({
86
+ reportName: 'my-report',
87
+ status: 'COMPLETED',
88
+ executionId: 'exec-1',
89
+ });
90
+ });
91
+
92
+ it('defaults mode to recorded and trigger to QUEUE', async () => {
93
+ mockRunner.execute.mockResolvedValue({
94
+ status: 'COMPLETED',
95
+ executionId: 'exec-d',
96
+ });
97
+
98
+ await handler({ reportName: 'my-report', params: {} });
99
+
100
+ expect(mockRunner.execute).toHaveBeenCalledWith(
101
+ 'my-report',
102
+ {},
103
+ expect.objectContaining({ mode: 'recorded', trigger: 'QUEUE' })
104
+ );
105
+ });
106
+
107
+ it('marks the execution FAILED when the runner throws', async () => {
108
+ mockRunner.execute.mockRejectedValue(new Error('boom'));
109
+
110
+ const response = await handler({
111
+ reportName: 'my-report',
112
+ executionId: 'exec-2',
113
+ trigger: 'SCHEDULED',
114
+ });
115
+
116
+ expect(response.statusCode).toBe(200);
117
+ const body = JSON.parse(response.body);
118
+ expect(body.results[0].status).toBe('FAILED');
119
+ expect(mockCommands.completeExecution).toHaveBeenCalledWith(
120
+ 'exec-2',
121
+ expect.objectContaining({ state: 'FAILED' })
122
+ );
123
+ });
124
+ });
125
+
126
+ describe('SQS batch (Records[])', () => {
127
+ it('injects the bootstrapped factory and processes each record', async () => {
128
+ mockRunner.execute.mockResolvedValue({
129
+ status: 'COMPLETED',
130
+ executionId: 'exec-3',
131
+ });
132
+
133
+ const response = await handler({
134
+ Records: [
135
+ {
136
+ body: JSON.stringify({
137
+ reportName: 'my-report',
138
+ executionId: 'exec-3',
139
+ mode: 'recorded',
140
+ params: {},
141
+ }),
142
+ },
143
+ ],
144
+ });
145
+
146
+ expect(createReportRunner).toHaveBeenCalledWith({
147
+ reportFactory: mockReportFactory,
148
+ reportCommands: { id: 'report-commands' },
149
+ friggCommands: mockReportFriggCommands,
150
+ integrationFactory: mockIntegrationFactory,
151
+ });
152
+ expect(mockRunner.execute).toHaveBeenCalledWith(
153
+ 'my-report',
154
+ {},
155
+ expect.objectContaining({
156
+ trigger: 'QUEUE',
157
+ mode: 'recorded',
158
+ executionId: 'exec-3',
159
+ })
160
+ );
161
+
162
+ expect(response.statusCode).toBe(200);
163
+ const body = JSON.parse(response.body);
164
+ expect(body.processed).toBe(1);
165
+ expect(body.results[0].status).toBe('COMPLETED');
166
+ });
167
+
168
+ it('isolates a bad record without dropping the rest of the batch', async () => {
169
+ mockRunner.execute.mockResolvedValue({
170
+ status: 'COMPLETED',
171
+ executionId: 'exec-4',
172
+ });
173
+
174
+ const response = await handler({
175
+ Records: [
176
+ // Missing reportName -> runMessage throws before the runner
177
+ { body: JSON.stringify({ executionId: 'exec-bad' }) },
178
+ {
179
+ body: JSON.stringify({
180
+ reportName: 'my-report',
181
+ executionId: 'exec-4',
182
+ params: {},
183
+ }),
184
+ },
185
+ ],
186
+ });
187
+
188
+ const body = JSON.parse(response.body);
189
+ expect(body.processed).toBe(2);
190
+ expect(body.results[0].status).toBe('FAILED');
191
+ expect(body.results[1].status).toBe('COMPLETED');
192
+ expect(mockCommands.completeExecution).toHaveBeenCalledWith(
193
+ 'exec-bad',
194
+ expect.objectContaining({ state: 'FAILED' })
195
+ );
196
+ });
197
+ });
198
+
199
+ describe('self-requeue on CONTINUE', () => {
200
+ afterEach(() => {
201
+ delete process.env.REPORT_QUEUE_URL;
202
+ });
203
+
204
+ it('passes the Lambda context to the runner', async () => {
205
+ mockRunner.execute.mockResolvedValue({
206
+ status: 'COMPLETED',
207
+ executionId: 'exec-ctx',
208
+ });
209
+ const lambdaContext = {
210
+ getRemainingTimeInMillis: () => 12345,
211
+ };
212
+
213
+ await handler(
214
+ { reportName: 'my-report', executionId: 'exec-ctx', params: {} },
215
+ lambdaContext
216
+ );
217
+
218
+ expect(mockRunner.execute).toHaveBeenCalledWith(
219
+ 'my-report',
220
+ {},
221
+ expect.objectContaining({ lambdaContext })
222
+ );
223
+ });
224
+
225
+ it('re-enqueues the same execution with a resume marker when the runner returns CONTINUE', async () => {
226
+ process.env.REPORT_QUEUE_URL = 'https://sqs.local/report-queue';
227
+ mockRunner.execute.mockResolvedValue({
228
+ status: 'CONTINUE',
229
+ executionId: 'exec-cont',
230
+ continuation: { cursor: 7 },
231
+ });
232
+
233
+ const response = await handler(
234
+ {
235
+ reportName: 'my-report',
236
+ executionId: 'exec-cont',
237
+ mode: 'recorded',
238
+ params: { since: '2026-01-01' },
239
+ },
240
+ { getRemainingTimeInMillis: () => 5000 }
241
+ );
242
+
243
+ expect(QueuerUtil.send).toHaveBeenCalledWith(
244
+ expect.objectContaining({
245
+ reportName: 'my-report',
246
+ executionId: 'exec-cont',
247
+ mode: 'recorded',
248
+ trigger: 'QUEUE',
249
+ params: {
250
+ since: '2026-01-01',
251
+ __resume: { cursor: 7 },
252
+ },
253
+ resumeAt: expect.any(String),
254
+ }),
255
+ 'https://sqs.local/report-queue'
256
+ );
257
+
258
+ const body = JSON.parse(response.body);
259
+ expect(body.results[0].status).toBe('CONTINUE');
260
+ });
261
+
262
+ it('fails the record when a continuation is yielded but REPORT_QUEUE_URL is unset', async () => {
263
+ delete process.env.REPORT_QUEUE_URL;
264
+ mockRunner.execute.mockResolvedValue({
265
+ status: 'CONTINUE',
266
+ executionId: 'exec-noqueue',
267
+ continuation: { cursor: 1 },
268
+ });
269
+
270
+ const response = await handler({
271
+ reportName: 'my-report',
272
+ executionId: 'exec-noqueue',
273
+ params: {},
274
+ });
275
+
276
+ expect(QueuerUtil.send).not.toHaveBeenCalled();
277
+ const body = JSON.parse(response.body);
278
+ expect(body.results[0].status).toBe('FAILED');
279
+ expect(mockCommands.completeExecution).toHaveBeenCalledWith(
280
+ 'exec-noqueue',
281
+ expect.objectContaining({ state: 'FAILED' })
282
+ );
283
+ });
284
+
285
+ it('compensates with the RUNNER-created id on a scheduled first run that cannot re-queue (no inbound executionId)', async () => {
286
+ // A real EventBridge scheduled event carries NO executionId — the
287
+ // runner creates the record. If the continuation can't be re-queued,
288
+ // the record must still be marked FAILED using the runner-created id,
289
+ // not the absent inbound one.
290
+ delete process.env.REPORT_QUEUE_URL;
291
+ mockRunner.execute.mockResolvedValue({
292
+ status: 'CONTINUE',
293
+ executionId: 'runner-created-exec',
294
+ continuation: { cursor: 1 },
295
+ });
296
+
297
+ const response = await handler({
298
+ reportName: 'my-report',
299
+ trigger: 'SCHEDULED',
300
+ mode: 'snapshot',
301
+ params: {},
302
+ // note: no executionId, matching the scheduler's buildInput
303
+ });
304
+
305
+ expect(QueuerUtil.send).not.toHaveBeenCalled();
306
+ expect(mockCommands.completeExecution).toHaveBeenCalledWith(
307
+ 'runner-created-exec',
308
+ expect.objectContaining({ state: 'FAILED' })
309
+ );
310
+ const body = JSON.parse(response.body);
311
+ expect(body.results[0].status).toBe('FAILED');
312
+ });
313
+ });
314
+ });