@friggframework/admin-scripts 2.0.0--canary.517.8d90337.0 → 2.0.0--canary.517.095e3f6.0

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 CHANGED
@@ -6,15 +6,12 @@
6
6
  */
7
7
 
8
8
  // Application Services
9
- const {
10
- ScriptFactory,
11
- getScriptFactory,
12
- } = require('./src/application/script-factory');
9
+ const { ScriptFactory } = require('./src/application/script-factory');
13
10
  const { AdminScriptBase } = require('./src/application/admin-script-base');
14
11
  const {
15
12
  AdminScriptContext,
16
13
  createAdminScriptContext,
17
- } = require('./src/application/admin-frigg-commands');
14
+ } = require('./src/application/admin-script-context');
18
15
  const {
19
16
  ScriptRunner,
20
17
  createScriptRunner,
@@ -47,7 +44,6 @@ module.exports = {
47
44
  // Application layer
48
45
  AdminScriptBase,
49
46
  ScriptFactory,
50
- getScriptFactory,
51
47
  AdminScriptContext,
52
48
  createAdminScriptContext,
53
49
  ScriptRunner,
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@friggframework/admin-scripts",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.517.8d90337.0",
4
+ "version": "2.0.0--canary.517.095e3f6.0",
5
5
  "description": "Admin Script Runner for Frigg - Execute maintenance and operational scripts in hosted environments",
6
6
  "dependencies": {
7
7
  "@aws-sdk/client-scheduler": "^3.588.0",
8
- "@friggframework/core": "2.0.0--canary.517.8d90337.0",
8
+ "@friggframework/core": "2.0.0--canary.517.095e3f6.0",
9
9
  "@hapi/boom": "^10.0.1",
10
10
  "express": "^4.18.2",
11
11
  "serverless-http": "^3.2.0"
12
12
  },
13
13
  "devDependencies": {
14
- "@friggframework/eslint-config": "2.0.0--canary.517.8d90337.0",
15
- "@friggframework/prettier-config": "2.0.0--canary.517.8d90337.0",
16
- "@friggframework/test": "2.0.0--canary.517.8d90337.0",
14
+ "@friggframework/eslint-config": "2.0.0--canary.517.095e3f6.0",
15
+ "@friggframework/prettier-config": "2.0.0--canary.517.095e3f6.0",
16
+ "@friggframework/test": "2.0.0--canary.517.095e3f6.0",
17
17
  "eslint": "^8.22.0",
18
18
  "jest": "^29.7.0",
19
19
  "prettier": "^2.7.1",
@@ -44,5 +44,5 @@
44
44
  "maintenance",
45
45
  "operations"
46
46
  ],
47
- "gitHead": "8d903378eed651256cbda1a3e1a6aab54fa46475"
47
+ "gitHead": "095e3f64ffc59fd7afc713a537c69a0ec48eef7b"
48
48
  }
@@ -1,7 +1,7 @@
1
1
  const {
2
2
  AdminScriptContext,
3
3
  createAdminScriptContext,
4
- } = require('../admin-frigg-commands');
4
+ } = require('../admin-script-context');
5
5
 
6
6
  // Mock all repository factories
7
7
  jest.mock(
@@ -1,4 +1,4 @@
1
- const { ScriptFactory, getScriptFactory } = require('../script-factory');
1
+ const { ScriptFactory } = require('../script-factory');
2
2
  const { AdminScriptBase } = require('../admin-script-base');
3
3
 
4
4
  describe('ScriptFactory', () => {
@@ -333,15 +333,32 @@ describe('ScriptFactory', () => {
333
333
  });
334
334
  });
335
335
 
336
- describe('Global factory functions', () => {
337
- it('getScriptFactory() should return singleton instance', () => {
338
- const factory1 = getScriptFactory();
339
- const factory2 = getScriptFactory();
336
+ describe('constructor', () => {
337
+ it('registers scripts passed to the constructor', () => {
338
+ class ScriptOne extends AdminScriptBase {
339
+ static Definition = {
340
+ name: 'script-one',
341
+ version: '1.0.0',
342
+ description: 'One',
343
+ };
344
+ }
345
+ class ScriptTwo extends AdminScriptBase {
346
+ static Definition = {
347
+ name: 'script-two',
348
+ version: '1.0.0',
349
+ description: 'Two',
350
+ };
351
+ }
340
352
 
341
- expect(factory1).toBe(factory2);
342
- expect(factory1).toBeInstanceOf(ScriptFactory);
353
+ const factory = new ScriptFactory([ScriptOne, ScriptTwo]);
354
+
355
+ expect(factory.size).toBe(2);
356
+ expect(factory.has('script-one')).toBe(true);
357
+ expect(factory.has('script-two')).toBe(true);
343
358
  });
359
+ });
344
360
 
361
+ describe('Instance independence', () => {
345
362
  it('new ScriptFactory() creates independent instances', () => {
346
363
  const factory1 = new ScriptFactory();
347
364
  const factory2 = new ScriptFactory();
@@ -351,7 +368,7 @@ describe('ScriptFactory', () => {
351
368
  expect(factory2).toBeInstanceOf(ScriptFactory);
352
369
  });
353
370
 
354
- it('global factory should be independent from created instances', () => {
371
+ it('registering into one factory does not affect another', () => {
355
372
  class TestScript extends AdminScriptBase {
356
373
  static Definition = {
357
374
  name: 'test',
@@ -360,18 +377,12 @@ describe('ScriptFactory', () => {
360
377
  };
361
378
  }
362
379
 
363
- const customFactory = new ScriptFactory();
364
- customFactory.register(TestScript);
365
-
366
- const globalFactory = getScriptFactory();
367
-
368
- // Custom factory has the script
369
- expect(customFactory.has('test')).toBe(true);
380
+ const factoryA = new ScriptFactory();
381
+ const factoryB = new ScriptFactory();
382
+ factoryA.register(TestScript);
370
383
 
371
- // Global factory doesn't (assuming it's empty or has different scripts)
372
- // We can't make assumptions about global factory state in tests
373
- // so we just verify they're different instances
374
- expect(customFactory).not.toBe(globalFactory);
384
+ expect(factoryA.has('test')).toBe(true);
385
+ expect(factoryB.has('test')).toBe(false);
375
386
  });
376
387
  });
377
388
 
@@ -3,10 +3,10 @@ const { ScriptFactory } = require('../script-factory');
3
3
  const { AdminScriptBase } = require('../admin-script-base');
4
4
 
5
5
  // Mock dependencies
6
- jest.mock('../admin-frigg-commands');
6
+ jest.mock('../admin-script-context');
7
7
  jest.mock('@friggframework/core/application/commands/admin-script-commands');
8
8
 
9
- const { createAdminScriptContext } = require('../admin-frigg-commands');
9
+ const { createAdminScriptContext } = require('../admin-script-context');
10
10
  const {
11
11
  createAdminScriptCommands,
12
12
  } = require('@friggframework/core/application/commands/admin-script-commands');
@@ -292,12 +292,13 @@ describe('ScriptRunner', () => {
292
292
  });
293
293
 
294
294
  describe('createScriptRunner()', () => {
295
- it('should create runner with default factory', () => {
296
- const runner = createScriptRunner();
297
- expect(runner).toBeInstanceOf(ScriptRunner);
295
+ it('should throw when no scriptFactory is provided', () => {
296
+ expect(() => createScriptRunner()).toThrow(
297
+ 'ScriptRunner requires a scriptFactory'
298
+ );
298
299
  });
299
300
 
300
- it('should create runner with custom params', () => {
301
+ it('should create runner with an injected factory', () => {
301
302
  const customFactory = new ScriptFactory();
302
303
  const runner = createScriptRunner({ scriptFactory: customFactory });
303
304
  expect(runner).toBeInstanceOf(ScriptRunner);
@@ -4,11 +4,18 @@
4
4
  * Registry and factory for admin scripts.
5
5
  * Manages script registration, validation, and instantiation.
6
6
  *
7
+ * Instances are created and owned by the caller (see bootstrap.js, which builds
8
+ * one per process and injects it into the runner and router). There is no global
9
+ * instance — pass a factory explicitly wherever one is needed.
10
+ *
7
11
  * Usage:
8
12
  * ```javascript
9
- * const factory = new ScriptFactory();
10
- * factory.register(MyScript);
11
- * const script = factory.createInstance('my-script', { executionId: '123' });
13
+ * const factory = new ScriptFactory([MyScript]);
14
+ * const script = factory.createInstance('my-script', {
15
+ * context,
16
+ * executionId,
17
+ * integrationFactory,
18
+ * });
12
19
  * ```
13
20
  */
14
21
  class ScriptFactory {
@@ -137,18 +144,4 @@ class ScriptFactory {
137
144
  }
138
145
  }
139
146
 
140
- // Singleton instance for global use
141
- let globalFactory = null;
142
-
143
- /**
144
- * Get global script factory instance
145
- * @returns {ScriptFactory} Global factory
146
- */
147
- function getScriptFactory() {
148
- if (!globalFactory) {
149
- globalFactory = new ScriptFactory();
150
- }
151
- return globalFactory;
152
- }
153
-
154
- module.exports = { ScriptFactory, getScriptFactory };
147
+ module.exports = { ScriptFactory };
@@ -1,5 +1,4 @@
1
- const { getScriptFactory } = require('./script-factory');
2
- const { createAdminScriptContext } = require('./admin-frigg-commands');
1
+ const { createAdminScriptContext } = require('./admin-script-context');
3
2
  const {
4
3
  createAdminScriptCommands,
5
4
  } = require('@friggframework/core/application/commands/admin-script-commands');
@@ -14,8 +13,20 @@ const {
14
13
  * - Status updates
15
14
  */
16
15
  class ScriptRunner {
16
+ /**
17
+ * @param {Object} params
18
+ * @param {ScriptFactory} params.scriptFactory - Required. The registry used
19
+ * to resolve and instantiate scripts by name (built by bootstrap.js).
20
+ * @param {Object} [params.commands] - Admin process command layer; defaults
21
+ * to a fresh createAdminScriptCommands().
22
+ * @param {Object} [params.integrationFactory] - Hydrates integration
23
+ * instances for scripts that need them.
24
+ */
17
25
  constructor(params = {}) {
18
- this.scriptFactory = params.scriptFactory || getScriptFactory();
26
+ if (!params.scriptFactory) {
27
+ throw new Error('ScriptRunner requires a scriptFactory');
28
+ }
29
+ this.scriptFactory = params.scriptFactory;
19
30
  this.commands = params.commands || createAdminScriptCommands();
20
31
  this.integrationFactory = params.integrationFactory || null;
21
32
  }
@@ -10,16 +10,13 @@ jest.mock('../admin-auth-middleware', () => ({
10
10
  },
11
11
  }));
12
12
 
13
- jest.mock('../../application/script-factory');
14
13
  jest.mock('../../application/script-runner');
15
14
  jest.mock('@friggframework/core/application/commands/admin-script-commands');
16
15
  jest.mock('@friggframework/core/queues');
17
16
  jest.mock('../../adapters/scheduler-adapter-factory');
18
- jest.mock('../bootstrap', () => ({
19
- bootstrapAdminScripts: () => ({ integrationFactory: {} }),
20
- }));
17
+ jest.mock('../bootstrap');
21
18
 
22
- const { getScriptFactory } = require('../../application/script-factory');
19
+ const { bootstrapAdminScripts } = require('../bootstrap');
23
20
  const { createScriptRunner } = require('../../application/script-runner');
24
21
  const {
25
22
  createAdminScriptCommands,
@@ -72,7 +69,10 @@ describe('Admin Script Router', () => {
72
69
  setScheduleEnabled: jest.fn(),
73
70
  };
74
71
 
75
- getScriptFactory.mockReturnValue(mockFactory);
72
+ bootstrapAdminScripts.mockReturnValue({
73
+ scriptFactory: mockFactory,
74
+ integrationFactory: {},
75
+ });
76
76
  createScriptRunner.mockReturnValue(mockRunner);
77
77
  createAdminScriptCommands.mockReturnValue(mockCommands);
78
78
  createSchedulerAdapter.mockReturnValue(mockSchedulerAdapter);
@@ -174,6 +174,10 @@ describe('Admin Script Router', () => {
174
174
  mode: 'sync',
175
175
  })
176
176
  );
177
+ expect(createScriptRunner).toHaveBeenCalledWith({
178
+ scriptFactory: mockFactory,
179
+ integrationFactory: {},
180
+ });
177
181
  });
178
182
 
179
183
  it('should queue script for async execution', async () => {
@@ -0,0 +1,78 @@
1
+ const { ScriptFactory } = require('../../application/script-factory');
2
+ const { AdminScriptBase } = require('../../application/admin-script-base');
3
+
4
+ jest.mock('@friggframework/core/handlers/app-definition-loader');
5
+
6
+ const {
7
+ loadAppDefinition,
8
+ } = require('@friggframework/core/handlers/app-definition-loader');
9
+ const {
10
+ bootstrapAdminScripts,
11
+ _resetBootstrapForTests,
12
+ } = require('../bootstrap');
13
+
14
+ class TestScript extends AdminScriptBase {
15
+ static Definition = {
16
+ name: 'test-script',
17
+ version: '1.0.0',
18
+ description: 'Test script',
19
+ };
20
+ }
21
+
22
+ describe('bootstrapAdminScripts', () => {
23
+ beforeEach(() => {
24
+ _resetBootstrapForTests();
25
+ jest.clearAllMocks();
26
+ jest.spyOn(console, 'error').mockImplementation(() => {});
27
+ });
28
+
29
+ afterEach(() => {
30
+ console.error.mockRestore();
31
+ });
32
+
33
+ it('registers the app definition admin scripts into the returned factory', () => {
34
+ loadAppDefinition.mockReturnValue({ adminScripts: [TestScript] });
35
+
36
+ const { scriptFactory, integrationFactory } = bootstrapAdminScripts();
37
+
38
+ expect(scriptFactory).toBeInstanceOf(ScriptFactory);
39
+ expect(scriptFactory.has('test-script')).toBe(true);
40
+ expect(integrationFactory).toBeTruthy();
41
+ });
42
+
43
+ it('never throws and returns an empty factory when the app definition cannot load', () => {
44
+ loadAppDefinition.mockImplementation(() => {
45
+ throw new Error('cannot load app definition');
46
+ });
47
+
48
+ let result;
49
+ expect(() => {
50
+ result = bootstrapAdminScripts();
51
+ }).not.toThrow();
52
+
53
+ expect(result.scriptFactory).toBeInstanceOf(ScriptFactory);
54
+ expect(result.scriptFactory.size).toBe(0);
55
+ expect(result.integrationFactory).toBeTruthy();
56
+ expect(console.error).toHaveBeenCalled();
57
+ });
58
+
59
+ it('memoizes: repeated calls reuse the same factory and load the definition once', () => {
60
+ loadAppDefinition.mockReturnValue({ adminScripts: [TestScript] });
61
+
62
+ const first = bootstrapAdminScripts();
63
+ const second = bootstrapAdminScripts();
64
+
65
+ expect(second.scriptFactory).toBe(first.scriptFactory);
66
+ expect(second.integrationFactory).toBe(first.integrationFactory);
67
+ expect(loadAppDefinition).toHaveBeenCalledTimes(1);
68
+ });
69
+
70
+ it('tolerates an app definition with no adminScripts', () => {
71
+ loadAppDefinition.mockReturnValue({});
72
+
73
+ const { scriptFactory } = bootstrapAdminScripts();
74
+
75
+ expect(scriptFactory).toBeInstanceOf(ScriptFactory);
76
+ expect(scriptFactory.size).toBe(0);
77
+ });
78
+ });
@@ -0,0 +1,163 @@
1
+ jest.mock('../bootstrap');
2
+ jest.mock('../../application/script-runner');
3
+ jest.mock('@friggframework/core/application/commands/admin-script-commands');
4
+
5
+ const { bootstrapAdminScripts } = require('../bootstrap');
6
+ const { createScriptRunner } = require('../../application/script-runner');
7
+ const {
8
+ createAdminScriptCommands,
9
+ } = require('@friggframework/core/application/commands/admin-script-commands');
10
+ const { handler } = require('../script-executor-handler');
11
+
12
+ describe('Admin Script Executor Handler', () => {
13
+ let mockScriptFactory;
14
+ let mockIntegrationFactory;
15
+ let mockRunner;
16
+ let mockCommands;
17
+
18
+ beforeEach(() => {
19
+ mockScriptFactory = { id: 'script-factory' };
20
+ mockIntegrationFactory = { id: 'integration-factory' };
21
+ mockRunner = { execute: jest.fn() };
22
+ mockCommands = {
23
+ completeAdminProcess: jest.fn().mockResolvedValue({}),
24
+ };
25
+
26
+ bootstrapAdminScripts.mockReturnValue({
27
+ scriptFactory: mockScriptFactory,
28
+ integrationFactory: mockIntegrationFactory,
29
+ });
30
+ createScriptRunner.mockReturnValue(mockRunner);
31
+ createAdminScriptCommands.mockReturnValue(mockCommands);
32
+
33
+ jest.spyOn(console, 'log').mockImplementation(() => {});
34
+ jest.spyOn(console, 'error').mockImplementation(() => {});
35
+ });
36
+
37
+ afterEach(() => {
38
+ console.log.mockRestore();
39
+ console.error.mockRestore();
40
+ jest.clearAllMocks();
41
+ });
42
+
43
+ describe('EventBridge Scheduler direct invoke (no Records)', () => {
44
+ it('injects the bootstrapped factory into the runner and reports the result', async () => {
45
+ mockRunner.execute.mockResolvedValue({
46
+ status: 'COMPLETED',
47
+ executionId: 'exec-1',
48
+ });
49
+
50
+ const response = await handler({
51
+ scriptName: 'my-script',
52
+ trigger: 'SCHEDULED',
53
+ params: { foo: 'bar' },
54
+ });
55
+
56
+ expect(createScriptRunner).toHaveBeenCalledWith({
57
+ scriptFactory: mockScriptFactory,
58
+ integrationFactory: mockIntegrationFactory,
59
+ });
60
+ expect(mockRunner.execute).toHaveBeenCalledWith(
61
+ 'my-script',
62
+ { foo: 'bar' },
63
+ expect.objectContaining({ trigger: 'SCHEDULED', mode: 'async' })
64
+ );
65
+
66
+ expect(response.statusCode).toBe(200);
67
+ const body = JSON.parse(response.body);
68
+ expect(body.processed).toBe(1);
69
+ expect(body.results[0]).toEqual({
70
+ scriptName: 'my-script',
71
+ status: 'COMPLETED',
72
+ executionId: 'exec-1',
73
+ });
74
+ });
75
+
76
+ it('marks the execution FAILED when the runner throws', async () => {
77
+ mockRunner.execute.mockRejectedValue(new Error('boom'));
78
+
79
+ const response = await handler({
80
+ scriptName: 'my-script',
81
+ executionId: 'exec-2',
82
+ trigger: 'SCHEDULED',
83
+ });
84
+
85
+ expect(response.statusCode).toBe(200);
86
+ const body = JSON.parse(response.body);
87
+ expect(body.results[0].status).toBe('FAILED');
88
+ expect(mockCommands.completeAdminProcess).toHaveBeenCalledWith(
89
+ 'exec-2',
90
+ expect.objectContaining({ state: 'FAILED' })
91
+ );
92
+ });
93
+ });
94
+
95
+ describe('SQS batch (Records[])', () => {
96
+ it('injects the bootstrapped factory and processes each record', async () => {
97
+ mockRunner.execute.mockResolvedValue({
98
+ status: 'COMPLETED',
99
+ executionId: 'exec-3',
100
+ });
101
+
102
+ const response = await handler({
103
+ Records: [
104
+ {
105
+ body: JSON.stringify({
106
+ scriptName: 'my-script',
107
+ executionId: 'exec-3',
108
+ params: {},
109
+ }),
110
+ },
111
+ ],
112
+ });
113
+
114
+ expect(createScriptRunner).toHaveBeenCalledWith({
115
+ scriptFactory: mockScriptFactory,
116
+ integrationFactory: mockIntegrationFactory,
117
+ });
118
+ expect(mockRunner.execute).toHaveBeenCalledWith(
119
+ 'my-script',
120
+ {},
121
+ expect.objectContaining({
122
+ trigger: 'QUEUE',
123
+ executionId: 'exec-3',
124
+ })
125
+ );
126
+
127
+ expect(response.statusCode).toBe(200);
128
+ const body = JSON.parse(response.body);
129
+ expect(body.processed).toBe(1);
130
+ expect(body.results[0].status).toBe('COMPLETED');
131
+ });
132
+
133
+ it('isolates a bad record without dropping the rest of the batch', async () => {
134
+ mockRunner.execute.mockResolvedValue({
135
+ status: 'COMPLETED',
136
+ executionId: 'exec-4',
137
+ });
138
+
139
+ const response = await handler({
140
+ Records: [
141
+ // Missing scriptName -> runMessage throws before the runner
142
+ { body: JSON.stringify({ executionId: 'exec-bad' }) },
143
+ {
144
+ body: JSON.stringify({
145
+ scriptName: 'my-script',
146
+ executionId: 'exec-4',
147
+ params: {},
148
+ }),
149
+ },
150
+ ],
151
+ });
152
+
153
+ const body = JSON.parse(response.body);
154
+ expect(body.processed).toBe(2);
155
+ expect(body.results[0].status).toBe('FAILED');
156
+ expect(body.results[1].status).toBe('COMPLETED');
157
+ expect(mockCommands.completeAdminProcess).toHaveBeenCalledWith(
158
+ 'exec-bad',
159
+ expect.objectContaining({ state: 'FAILED' })
160
+ );
161
+ });
162
+ });
163
+ });
@@ -2,7 +2,6 @@ const express = require('express');
2
2
  const serverless = require('serverless-http');
3
3
  const Boom = require('@hapi/boom');
4
4
  const { validateAdminApiKey } = require('./admin-auth-middleware');
5
- const { getScriptFactory } = require('../application/script-factory');
6
5
  const { createScriptRunner } = require('../application/script-runner');
7
6
  const {
8
7
  validateScriptInput,
@@ -27,10 +26,13 @@ const router = express.Router();
27
26
  // Apply auth middleware to all admin routes
28
27
  router.use(validateAdminApiKey);
29
28
 
30
- // Register the host app's admin scripts before handling requests.
31
- // Memoized, so this only does work on the first request per process.
32
- router.use((_req, _res, next) => {
33
- bootstrapAdminScripts();
29
+ // Build (once, memoized) and inject the per-process dependencies onto the
30
+ // request, so route handlers consume them explicitly instead of reaching for a
31
+ // global. Registers the host app's admin scripts on the first request.
32
+ router.use((req, _res, next) => {
33
+ const { scriptFactory, integrationFactory } = bootstrapAdminScripts();
34
+ req.scriptFactory = scriptFactory;
35
+ req.integrationFactory = integrationFactory;
34
36
  next();
35
37
  });
36
38
 
@@ -42,7 +44,9 @@ router.use((_req, _res, next) => {
42
44
  */
43
45
  function sendError(res, error, fallbackMessage) {
44
46
  if (error.isBoom) {
45
- return res.status(error.output.statusCode).json({ error: error.message });
47
+ return res
48
+ .status(error.output.statusCode)
49
+ .json({ error: error.message });
46
50
  }
47
51
  console.error(fallbackMessage, error);
48
52
  return res.status(500).json({ error: fallbackMessage });
@@ -65,9 +69,10 @@ function buildAudit(req) {
65
69
 
66
70
  /**
67
71
  * Create schedule use case instances
72
+ * @param {ScriptFactory} scriptFactory - Registry injected from the request.
68
73
  * @private
69
74
  */
70
- function createScheduleUseCases() {
75
+ function createScheduleUseCases(scriptFactory) {
71
76
  const commands = createAdminScriptCommands();
72
77
 
73
78
  // The local adapter is in-memory only (schedules vanish on cold start), so it
@@ -88,7 +93,6 @@ function createScheduleUseCases() {
88
93
  scheduleGroupName: process.env.ADMIN_SCRIPT_SCHEDULE_GROUP,
89
94
  roleArn: process.env.SCHEDULER_ROLE_ARN,
90
95
  });
91
- const scriptFactory = getScriptFactory();
92
96
 
93
97
  return {
94
98
  getEffectiveSchedule: new GetEffectiveScheduleUseCase({
@@ -114,7 +118,7 @@ function createScheduleUseCases() {
114
118
  */
115
119
  router.get('/scripts', async (req, res) => {
116
120
  try {
117
- const factory = getScriptFactory();
121
+ const factory = req.scriptFactory;
118
122
  const scripts = factory.getAll();
119
123
 
120
124
  res.json({
@@ -140,7 +144,7 @@ router.get('/scripts', async (req, res) => {
140
144
  router.get('/scripts/:scriptName', async (req, res) => {
141
145
  try {
142
146
  const { scriptName } = req.params;
143
- const factory = getScriptFactory();
147
+ const factory = req.scriptFactory;
144
148
 
145
149
  if (!factory.has(scriptName)) {
146
150
  return res.status(404).json({
@@ -175,7 +179,7 @@ router.post('/scripts/:scriptName/validate', async (req, res) => {
175
179
  try {
176
180
  const { scriptName } = req.params;
177
181
  const { params = {} } = req.body;
178
- const factory = getScriptFactory();
182
+ const factory = req.scriptFactory;
179
183
 
180
184
  if (!factory.has(scriptName)) {
181
185
  return res.status(404).json({
@@ -200,7 +204,7 @@ router.post('/scripts/:scriptName', async (req, res) => {
200
204
  try {
201
205
  const { scriptName } = req.params;
202
206
  const { params = {}, mode = 'async' } = req.body;
203
- const factory = getScriptFactory();
207
+ const factory = req.scriptFactory;
204
208
 
205
209
  if (!factory.has(scriptName)) {
206
210
  return res.status(404).json({
@@ -237,8 +241,10 @@ router.post('/scripts/:scriptName', async (req, res) => {
237
241
  });
238
242
  }
239
243
 
240
- const { integrationFactory } = bootstrapAdminScripts();
241
- const runner = createScriptRunner({ integrationFactory });
244
+ const runner = createScriptRunner({
245
+ scriptFactory: req.scriptFactory,
246
+ integrationFactory: req.integrationFactory,
247
+ });
242
248
  const result = await runner.execute(scriptName, params, {
243
249
  trigger: 'MANUAL',
244
250
  mode: 'sync',
@@ -355,7 +361,9 @@ router.get('/scripts/:scriptName/executions', async (req, res) => {
355
361
  router.get('/scripts/:scriptName/schedule', async (req, res) => {
356
362
  try {
357
363
  const { scriptName } = req.params;
358
- const { getEffectiveSchedule } = createScheduleUseCases();
364
+ const { getEffectiveSchedule } = createScheduleUseCases(
365
+ req.scriptFactory
366
+ );
359
367
 
360
368
  const result = await getEffectiveSchedule.execute(scriptName);
361
369
 
@@ -377,7 +385,7 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => {
377
385
  try {
378
386
  const { scriptName } = req.params;
379
387
  const { enabled, cronExpression, timezone } = req.body;
380
- const { upsertSchedule } = createScheduleUseCases();
388
+ const { upsertSchedule } = createScheduleUseCases(req.scriptFactory);
381
389
 
382
390
  const result = await upsertSchedule.execute(scriptName, {
383
391
  enabled,
@@ -407,7 +415,7 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => {
407
415
  router.delete('/scripts/:scriptName/schedule', async (req, res) => {
408
416
  try {
409
417
  const { scriptName } = req.params;
410
- const { deleteSchedule } = createScheduleUseCases();
418
+ const { deleteSchedule } = createScheduleUseCases(req.scriptFactory);
411
419
 
412
420
  const result = await deleteSchedule.execute(scriptName);
413
421
 
@@ -1,18 +1,20 @@
1
- const { getScriptFactory } = require('../application/script-factory');
1
+ const { ScriptFactory } = require('../application/script-factory');
2
2
 
3
3
  /**
4
4
  * Admin Script Bootstrap
5
5
  *
6
- * Loads the host app's definition at Lambda runtime and registers its admin
7
- * scripts (and the built-ins, when enabled) into the global ScriptFactory, so
8
- * the router and SQS worker can resolve scripts by name. Also constructs the
9
- * integrationFactory used by scripts that need hydrated integration instances.
6
+ * Composition root for the admin-scripts runtime. Loads the host app's
7
+ * definition at Lambda runtime, builds a ScriptFactory, and registers the app's
8
+ * admin scripts into it so the router and SQS worker can resolve scripts by
9
+ * name. Also constructs the integrationFactory used by scripts that need
10
+ * hydrated integration instances. Both are returned for the caller to inject.
10
11
  *
11
12
  * Runs once per process (memoized) and never throws — a missing/unloadable app
12
13
  * definition is logged and leaves the factory empty rather than crashing the
13
14
  * Lambda cold start.
14
15
  */
15
16
  let bootstrapped = false;
17
+ let scriptFactory = null;
16
18
  let integrationFactory = null;
17
19
 
18
20
  function registerScripts(factory, scriptClasses) {
@@ -43,22 +45,26 @@ function createIntegrationFactory() {
43
45
  }
44
46
 
45
47
  /**
46
- * @returns {{ integrationFactory: object }}
48
+ * @returns {{ scriptFactory: ScriptFactory, integrationFactory: object }}
47
49
  */
48
50
  function bootstrapAdminScripts() {
49
51
  if (bootstrapped) {
50
- return { integrationFactory };
52
+ return { scriptFactory, integrationFactory };
51
53
  }
52
54
  bootstrapped = true;
53
55
 
56
+ // Create the registry up front so consumers always get a (possibly empty)
57
+ // factory even when the app definition can't be loaded — mirrors the
58
+ // never-throw contract above.
59
+ scriptFactory = new ScriptFactory();
60
+
54
61
  try {
55
62
  const {
56
63
  loadAppDefinition,
57
64
  } = require('@friggframework/core/handlers/app-definition-loader');
58
65
  const { adminScripts = [] } = loadAppDefinition();
59
66
 
60
- const factory = getScriptFactory();
61
- registerScripts(factory, adminScripts);
67
+ registerScripts(scriptFactory, adminScripts);
62
68
  } catch (error) {
63
69
  console.error(
64
70
  '[admin-scripts] bootstrap: could not load app definition:',
@@ -67,12 +73,13 @@ function bootstrapAdminScripts() {
67
73
  }
68
74
 
69
75
  integrationFactory = createIntegrationFactory();
70
- return { integrationFactory };
76
+ return { scriptFactory, integrationFactory };
71
77
  }
72
78
 
73
79
  /** Test-only: reset memoized bootstrap state. */
74
80
  function _resetBootstrapForTests() {
75
81
  bootstrapped = false;
82
+ scriptFactory = null;
76
83
  integrationFactory = null;
77
84
  }
78
85
 
@@ -6,9 +6,20 @@ const { bootstrapAdminScripts } = require('./bootstrap');
6
6
 
7
7
  /**
8
8
  * Run a single execution message through the ScriptRunner.
9
+ * @param {Object} message - Parsed execution message.
10
+ * @param {string} message.scriptName - Name of the registered script to run (required).
11
+ * @param {string} [message.executionId] - Existing AdminProcess id to resume; when
12
+ * absent, ScriptRunner creates a new record.
13
+ * @param {string} [message.trigger] - Execution trigger; defaults to 'QUEUE'.
14
+ * @param {Object} [message.params] - Parameters passed to the script.
15
+ * @param {string} [message.parentExecutionId] - Parent execution id for queueScript continuations.
16
+ * @param {Object} deps
17
+ * @param {ScriptFactory} deps.scriptFactory - Registry used to resolve and instantiate the script.
18
+ * @param {Object} deps.integrationFactory - Hydrates integration instances for scripts that need them.
19
+ * @returns {Promise<{ scriptName: string, status: string, executionId: string }>}
9
20
  * @private
10
21
  */
11
- async function runMessage(message, integrationFactory) {
22
+ async function runMessage(message, { scriptFactory, integrationFactory }) {
12
23
  const { scriptName, executionId, trigger, params, parentExecutionId } =
13
24
  message;
14
25
 
@@ -22,7 +33,7 @@ async function runMessage(message, integrationFactory) {
22
33
  }`
23
34
  );
24
35
 
25
- const runner = createScriptRunner({ integrationFactory });
36
+ const runner = createScriptRunner({ scriptFactory, integrationFactory });
26
37
  const result = await runner.execute(scriptName, params, {
27
38
  trigger: trigger || 'QUEUE',
28
39
  mode: 'async',
@@ -66,59 +77,58 @@ async function markFailed(executionId, error) {
66
77
  }
67
78
 
68
79
  /**
69
- * Admin Script Executor Lambda Handler
70
- *
71
- * Handles two invocation shapes:
72
- * - SQS: `event.Records[]` each record body is a JSON execution message
73
- * (manual async executions and queueScript continuations).
74
- * - EventBridge Scheduler direct invoke: the event itself is the message
75
- * (`{ scriptName, trigger: 'SCHEDULED', params }`) with no `Records` wrapper.
76
- *
77
- * Thin adapter: parses the event and delegates to ScriptRunner, which owns
78
- * execution tracking, error recording, and status updates.
80
+ * Handle an EventBridge Scheduler direct invoke: the event itself is a single
81
+ * execution message (no `Records` wrapper).
82
+ * @param {Object} event - The execution message.
83
+ * @param {{ scriptFactory: ScriptFactory, integrationFactory: object }} deps
84
+ * @returns {Promise<{ statusCode: number, body: string }>}
85
+ * @private
79
86
  */
80
- async function handler(event) {
81
- const { integrationFactory } = bootstrapAdminScripts();
82
-
83
- // EventBridge Scheduler invokes the Lambda directly (no Records wrapper)
84
- if (!event.Records) {
85
- try {
86
- const result = await runMessage(event, integrationFactory);
87
- console.log(
88
- `Script completed: ${result.scriptName}, status: ${result.status}`
89
- );
90
- return {
91
- statusCode: 200,
92
- body: JSON.stringify({ processed: 1, results: [result] }),
93
- };
94
- } catch (error) {
95
- console.error(
96
- 'Unexpected error processing scheduled invoke:',
97
- error
98
- );
99
- await markFailed(event.executionId, error);
100
- return {
101
- statusCode: 200,
102
- body: JSON.stringify({
103
- processed: 1,
104
- results: [
105
- {
106
- scriptName: event.scriptName || 'unknown',
107
- status: 'FAILED',
108
- error: error.message,
109
- },
110
- ],
111
- }),
112
- };
113
- }
87
+ async function handleScheduledInvoke(event, deps) {
88
+ try {
89
+ const result = await runMessage(event, deps);
90
+ console.log(
91
+ `Script completed: ${result.scriptName}, status: ${result.status}`
92
+ );
93
+ return {
94
+ statusCode: 200,
95
+ body: JSON.stringify({ processed: 1, results: [result] }),
96
+ };
97
+ } catch (error) {
98
+ console.error('Unexpected error processing scheduled invoke:', error);
99
+ await markFailed(event.executionId, error);
100
+ return {
101
+ statusCode: 200,
102
+ body: JSON.stringify({
103
+ processed: 1,
104
+ results: [
105
+ {
106
+ scriptName: event.scriptName || 'unknown',
107
+ status: 'FAILED',
108
+ error: error.message,
109
+ },
110
+ ],
111
+ }),
112
+ };
114
113
  }
114
+ }
115
115
 
116
+ /**
117
+ * Handle an SQS batch: each `event.Records[].body` is a JSON execution message
118
+ * (manual async executions and queueScript continuations). Failures are isolated
119
+ * per record so one bad message doesn't drop the rest of the batch.
120
+ * @param {Object} event - The SQS event with a `Records` array.
121
+ * @param {{ scriptFactory: ScriptFactory, integrationFactory: object }} deps
122
+ * @returns {Promise<{ statusCode: number, body: string }>}
123
+ * @private
124
+ */
125
+ async function handleSqsBatch(event, deps) {
116
126
  const results = [];
117
127
  for (const record of event.Records) {
118
128
  let message = {};
119
129
  try {
120
130
  message = JSON.parse(record.body);
121
- const result = await runMessage(message, integrationFactory);
131
+ const result = await runMessage(message, deps);
122
132
  console.log(
123
133
  `Script completed: ${result.scriptName}, status: ${result.status}`
124
134
  );
@@ -143,4 +153,24 @@ async function handler(event) {
143
153
  };
144
154
  }
145
155
 
156
+ /**
157
+ * Admin Script Executor Lambda Handler
158
+ *
159
+ * Handles two invocation shapes:
160
+ * - SQS: `event.Records[]` — each record body is a JSON execution message
161
+ * (manual async executions and queueScript continuations).
162
+ * - EventBridge Scheduler direct invoke: the event itself is the message
163
+ * (`{ scriptName, trigger: 'SCHEDULED', params }`) with no `Records` wrapper.
164
+ *
165
+ * Thin adapter: parses the event and delegates to ScriptRunner, which owns
166
+ * execution tracking, error recording, and status updates.
167
+ */
168
+ async function handler(event) {
169
+ const deps = bootstrapAdminScripts();
170
+
171
+ return event.Records
172
+ ? handleSqsBatch(event, deps)
173
+ : handleScheduledInvoke(event, deps);
174
+ }
175
+
146
176
  module.exports = { handler };