@friggframework/admin-scripts 2.0.0--canary.517.f8e0cc1.0 → 2.0.0--canary.517.44c158e.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @friggframework/admin-scripts
2
2
 
3
- Admin Script Runner for Frigg — write and run operational/maintenance scripts inside your deployed Frigg app, with VPC/KMS-secured database access, the same repositories your integrations use, sync or async (queued) execution, dry-run validation, and optional cron scheduling via AWS EventBridge Scheduler.
3
+ Admin Script Runner for Frigg — write and run operational/maintenance scripts inside your deployed Frigg app, with VPC/KMS-secured database access through the same Frigg commands your integrations use, sync or async (queued) execution, dry-run validation, and optional cron scheduling via AWS EventBridge Scheduler.
4
4
 
5
5
  Typical use cases:
6
6
 
@@ -87,12 +87,17 @@ class AttioHealingScript extends AdminScriptBase {
87
87
 
88
88
  this.context.log('info', 'Starting Attio healing', { integrationId });
89
89
 
90
+ // Read persisted data through Frigg commands (never repositories).
91
+ // Commands return the data on success, or an { error, reason } object
92
+ // on failure — check `.error` yourself.
90
93
  const integration =
91
- await this.context.integrationRepository.findIntegrationById(
94
+ await this.context.commands.integrations.findIntegrationById(
92
95
  integrationId
93
96
  );
94
- if (!integration) {
95
- throw new Error(`Integration ${integrationId} not found`);
97
+ if (integration.error) {
98
+ throw new Error(
99
+ `Integration ${integrationId} not found: ${integration.reason}`
100
+ );
96
101
  }
97
102
 
98
103
  // Call the live integration when you need to hit an external API.
@@ -117,16 +122,16 @@ module.exports = { AttioHealingScript };
117
122
  | Member | Description |
118
123
  | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
119
124
  | `log(level, message, data?)` | Records a structured log entry (`level`: `debug`/`info`/`warn`/`error`). Logs are persisted to the execution's `results.logs`. |
120
- | `integrationRepository` | Read/query integrations. |
121
- | `userRepository` | Read/query users. |
122
- | `moduleRepository` | Read/query modules. |
123
- | `credentialRepository` | Read credentials (decrypted transparently). |
125
+ | `commands.users` | User commands (`findIndividualUserById`, `createUser`, `updateUser`, …). |
126
+ | `commands.credentials` | Credential commands (`findCredential`, `updateCredential`, …); secrets decrypted transparently. |
127
+ | `commands.entities` | Entity/module commands (`findEntityById`, `findEntitiesByUserId`, …). |
128
+ | `commands.integrations` | Integration reads (`findIntegrationById`, `listIntegrations({ type, status })`). |
124
129
  | `instantiate(integrationId)` | Returns a hydrated integration instance for calling external APIs. Requires `config.requireIntegrationInstance: true`. |
125
130
  | `queueScript(name, params?)` | Enqueue another script as a follow-up (tracked as a child of the current execution). |
126
131
  | `queueScriptBatch(entries)` | Enqueue many follow-up scripts at once. |
127
132
  | `getExecutionId()` | The current `AdminProcess` record id. |
128
133
 
129
- Repositories return **plain, decrypted data** field-level encryption is handled transparently.
134
+ Commands return **plain, decrypted data** on success (field-level encryption is handled transparently) or an `{ error, reason, code }` object on failure — scripts check `.error` themselves. Scripts have no direct repository access; all database interaction goes through the command layer.
130
135
 
131
136
  ---
132
137
 
package/index.js CHANGED
@@ -6,10 +6,7 @@
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,
@@ -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.f8e0cc1.0",
4
+ "version": "2.0.0--canary.517.44c158e.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.f8e0cc1.0",
8
+ "@friggframework/core": "2.0.0--canary.517.44c158e.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.f8e0cc1.0",
15
- "@friggframework/prettier-config": "2.0.0--canary.517.f8e0cc1.0",
16
- "@friggframework/test": "2.0.0--canary.517.f8e0cc1.0",
14
+ "@friggframework/eslint-config": "2.0.0--canary.517.44c158e.0",
15
+ "@friggframework/prettier-config": "2.0.0--canary.517.44c158e.0",
16
+ "@friggframework/test": "2.0.0--canary.517.44c158e.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": "f8e0cc122e1580a8fe61fa3fb9b2b9f39f461e61"
47
+ "gitHead": "44c158e7a3bbeaac4b410c1062b0ce209bffd7f0"
48
48
  }
@@ -1,80 +1,24 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
1
3
  const {
2
4
  AdminScriptContext,
3
5
  createAdminScriptContext,
4
6
  } = require('../admin-script-context');
5
7
 
6
- // Mock all repository factories
7
- jest.mock(
8
- '@friggframework/core/integrations/repositories/integration-repository-factory'
9
- );
10
- jest.mock('@friggframework/core/user/repositories/user-repository-factory');
11
- jest.mock(
12
- '@friggframework/core/modules/repositories/module-repository-factory'
13
- );
14
- jest.mock(
15
- '@friggframework/core/credential/repositories/credential-repository-factory'
16
- );
17
8
  jest.mock('@friggframework/core/queues');
18
9
 
19
10
  describe('AdminScriptContext', () => {
20
- let mockIntegrationRepo;
21
- let mockUserRepo;
22
- let mockModuleRepo;
23
- let mockCredentialRepo;
24
11
  let mockQueuerUtil;
25
12
 
26
13
  beforeEach(() => {
27
14
  jest.clearAllMocks();
28
15
 
29
- mockIntegrationRepo = {
30
- findIntegrations: jest.fn(),
31
- findIntegrationById: jest.fn(),
32
- findIntegrationsByUserId: jest.fn(),
33
- updateIntegrationConfig: jest.fn(),
34
- updateIntegrationStatus: jest.fn(),
35
- };
36
-
37
- mockUserRepo = {
38
- findIndividualUserById: jest.fn(),
39
- findIndividualUserByAppUserId: jest.fn(),
40
- findIndividualUserByUsername: jest.fn(),
41
- };
42
-
43
- mockModuleRepo = {
44
- findEntity: jest.fn(),
45
- findEntityById: jest.fn(),
46
- findEntitiesByUserId: jest.fn(),
47
- };
48
-
49
- mockCredentialRepo = {
50
- findCredential: jest.fn(),
51
- updateCredential: jest.fn(),
52
- };
53
-
54
16
  mockQueuerUtil = {
55
17
  send: jest.fn().mockResolvedValue(undefined),
56
18
  batchSend: jest.fn().mockResolvedValue(undefined),
57
19
  };
58
20
 
59
- const {
60
- createIntegrationRepository,
61
- } = require('@friggframework/core/integrations/repositories/integration-repository-factory');
62
- const {
63
- createUserRepository,
64
- } = require('@friggframework/core/user/repositories/user-repository-factory');
65
- const {
66
- createModuleRepository,
67
- } = require('@friggframework/core/modules/repositories/module-repository-factory');
68
- const {
69
- createCredentialRepository,
70
- } = require('@friggframework/core/credential/repositories/credential-repository-factory');
71
21
  const { QueuerUtil } = require('@friggframework/core/queues');
72
-
73
- createIntegrationRepository.mockReturnValue(mockIntegrationRepo);
74
- createUserRepository.mockReturnValue(mockUserRepo);
75
- createModuleRepository.mockReturnValue(mockModuleRepo);
76
- createCredentialRepository.mockReturnValue(mockCredentialRepo);
77
-
78
22
  QueuerUtil.send = mockQueuerUtil.send;
79
23
  QueuerUtil.batchSend = mockQueuerUtil.batchSend;
80
24
  });
@@ -86,6 +30,19 @@ describe('AdminScriptContext', () => {
86
30
  expect(ctx.executionId).toBe('exec_123');
87
31
  expect(ctx.logs).toEqual([]);
88
32
  expect(ctx.integrationFactory).toBeNull();
33
+ expect(ctx.commands).toBeNull();
34
+ });
35
+
36
+ it('exposes the injected command bundle as context.commands', () => {
37
+ const commands = {
38
+ users: {},
39
+ credentials: {},
40
+ entities: {},
41
+ integrations: {},
42
+ };
43
+ const ctx = new AdminScriptContext({ commands });
44
+
45
+ expect(ctx.commands).toBe(commands);
89
46
  });
90
47
 
91
48
  it('creates with integrationFactory', () => {
@@ -106,71 +63,23 @@ describe('AdminScriptContext', () => {
106
63
  });
107
64
  });
108
65
 
109
- describe('Lazy Repository Loading', () => {
110
- it('creates integrationRepository on first access', () => {
66
+ describe('command surface (no direct repository access)', () => {
67
+ it('does not expose repository getters', () => {
111
68
  const ctx = new AdminScriptContext();
112
- const {
113
- createIntegrationRepository,
114
- } = require('@friggframework/core/integrations/repositories/integration-repository-factory');
115
-
116
- expect(createIntegrationRepository).not.toHaveBeenCalled();
117
69
 
118
- const repo = ctx.integrationRepository;
119
-
120
- expect(createIntegrationRepository).toHaveBeenCalledTimes(1);
121
- expect(repo).toBe(mockIntegrationRepo);
70
+ expect(ctx.integrationRepository).toBeUndefined();
71
+ expect(ctx.userRepository).toBeUndefined();
72
+ expect(ctx.moduleRepository).toBeUndefined();
73
+ expect(ctx.credentialRepository).toBeUndefined();
122
74
  });
123
75
 
124
- it('returns same instance on subsequent access', () => {
125
- const ctx = new AdminScriptContext();
126
-
127
- const repo1 = ctx.integrationRepository;
128
- const repo2 = ctx.integrationRepository;
129
-
130
- expect(repo1).toBe(repo2);
131
- expect(repo1).toBe(mockIntegrationRepo);
132
- });
133
-
134
- it('creates userRepository on first access', () => {
135
- const ctx = new AdminScriptContext();
136
- const {
137
- createUserRepository,
138
- } = require('@friggframework/core/user/repositories/user-repository-factory');
139
-
140
- expect(createUserRepository).not.toHaveBeenCalled();
141
-
142
- const repo = ctx.userRepository;
143
-
144
- expect(createUserRepository).toHaveBeenCalledTimes(1);
145
- expect(repo).toBe(mockUserRepo);
146
- });
147
-
148
- it('creates moduleRepository on first access', () => {
149
- const ctx = new AdminScriptContext();
150
- const {
151
- createModuleRepository,
152
- } = require('@friggframework/core/modules/repositories/module-repository-factory');
153
-
154
- expect(createModuleRepository).not.toHaveBeenCalled();
155
-
156
- const repo = ctx.moduleRepository;
157
-
158
- expect(createModuleRepository).toHaveBeenCalledTimes(1);
159
- expect(repo).toBe(mockModuleRepo);
160
- });
161
-
162
- it('creates credentialRepository on first access', () => {
163
- const ctx = new AdminScriptContext();
164
- const {
165
- createCredentialRepository,
166
- } = require('@friggframework/core/credential/repositories/credential-repository-factory');
167
-
168
- expect(createCredentialRepository).not.toHaveBeenCalled();
169
-
170
- const repo = ctx.credentialRepository;
76
+ it('the context module never imports a repository factory', () => {
77
+ const source = fs.readFileSync(
78
+ path.join(__dirname, '..', 'admin-script-context.js'),
79
+ 'utf8'
80
+ );
171
81
 
172
- expect(createCredentialRepository).toHaveBeenCalledTimes(1);
173
- expect(repo).toBe(mockCredentialRepo);
82
+ expect(source).not.toMatch(/repository-factory/);
174
83
  });
175
84
  });
176
85
 
@@ -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
 
@@ -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);
@@ -3,23 +3,27 @@ const { QueuerUtil } = require('@friggframework/core/queues');
3
3
  /**
4
4
  * AdminScriptContext - Execution environment for admin scripts
5
5
  *
6
- * Provides a controlled surface area for scripts to interact with
7
- * the Frigg platform. Unique capabilities vs direct repo access:
6
+ * Provides a controlled surface area for scripts to interact with the Frigg
7
+ * platform. Scripts touch the database only through injected Frigg commands
8
+ * (`context.commands`) — never through repositories directly. Capabilities:
8
9
  *
10
+ * - **Frigg commands**: `commands.users`, `commands.credentials`,
11
+ * `commands.entities`, and `commands.integrations` expose the framework's
12
+ * command layer. Each command returns data on success or an `{ error }`
13
+ * object on failure — scripts check `.error` themselves.
9
14
  * - **Admin bypass**: `instantiate()` passes `_isAdminContext: true` to
10
15
  * skip user-ownership checks when loading integration instances
11
16
  * - **Script chaining**: `queueScript()` / `queueScriptBatch()` let scripts
12
17
  * enqueue follow-up work with parent execution tracking
13
18
  * - **Execution-scoped logging**: `log()` collects structured entries tied
14
19
  * to the current execution for post-run inspection
15
- * - **Lazy-loaded repositories**: Repos are exposed directly as getters
16
- * so scripts can query any data they need without wrapper indirection
17
20
  */
18
21
  class AdminScriptContext {
19
22
  /**
20
23
  * @param {Object} [params={}] - Context configuration
21
24
  * @param {string|number|null} [params.executionId] - ID of the AdminProcess record this context is scoped to (used for log persistence and script chaining)
22
25
  * @param {Object|null} [params.integrationFactory] - Factory used to hydrate integration instances; required for scripts that call instantiate()
26
+ * @param {Object|null} [params.commands] - Frigg command bundle ({ users, credentials, entities, integrations }) injected by the composition root and exposed to scripts as context.commands
23
27
  */
24
28
  constructor(params = {}) {
25
29
  this.executionId = params.executionId || null;
@@ -27,54 +31,10 @@ class AdminScriptContext {
27
31
 
28
32
  this.integrationFactory = params.integrationFactory || null;
29
33
 
30
- // Repositories are created on first use so the Prisma client is only
31
- // initialized (and only for the repos a script actually touches) when needed.
32
- this._integrationRepository = null;
33
- this._userRepository = null;
34
- this._moduleRepository = null;
35
- this._credentialRepository = null;
36
- }
37
-
38
- // ==================== LAZY-LOADED REPOSITORIES ====================
39
-
40
- get integrationRepository() {
41
- if (!this._integrationRepository) {
42
- const {
43
- createIntegrationRepository,
44
- } = require('@friggframework/core/integrations/repositories/integration-repository-factory');
45
- this._integrationRepository = createIntegrationRepository();
46
- }
47
- return this._integrationRepository;
48
- }
49
-
50
- get userRepository() {
51
- if (!this._userRepository) {
52
- const {
53
- createUserRepository,
54
- } = require('@friggframework/core/user/repositories/user-repository-factory');
55
- this._userRepository = createUserRepository();
56
- }
57
- return this._userRepository;
58
- }
59
-
60
- get moduleRepository() {
61
- if (!this._moduleRepository) {
62
- const {
63
- createModuleRepository,
64
- } = require('@friggframework/core/modules/repositories/module-repository-factory');
65
- this._moduleRepository = createModuleRepository();
66
- }
67
- return this._moduleRepository;
68
- }
69
-
70
- get credentialRepository() {
71
- if (!this._credentialRepository) {
72
- const {
73
- createCredentialRepository,
74
- } = require('@friggframework/core/credential/repositories/credential-repository-factory');
75
- this._credentialRepository = createCredentialRepository();
76
- }
77
- return this._credentialRepository;
34
+ // Scripts interact with the database only through these Frigg commands.
35
+ // Injected by bootstrap.js the context never reaches for repositories
36
+ // or command factories itself.
37
+ this.commands = params.commands || null;
78
38
  }
79
39
 
80
40
  // ==================== INTEGRATION INSTANTIATION ====================
@@ -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,4 +1,3 @@
1
- const { getScriptFactory } = require('./script-factory');
2
1
  const { createAdminScriptContext } = require('./admin-script-context');
3
2
  const {
4
3
  createAdminScriptCommands,
@@ -14,10 +13,25 @@ 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
+ * @param {Object} [params.scriptCommands] - Frigg command bundle exposed to
25
+ * scripts as context.commands (built by bootstrap.js).
26
+ */
17
27
  constructor(params = {}) {
18
- this.scriptFactory = params.scriptFactory || getScriptFactory();
28
+ if (!params.scriptFactory) {
29
+ throw new Error('ScriptRunner requires a scriptFactory');
30
+ }
31
+ this.scriptFactory = params.scriptFactory;
19
32
  this.commands = params.commands || createAdminScriptCommands();
20
33
  this.integrationFactory = params.integrationFactory || null;
34
+ this.scriptCommands = params.scriptCommands || null;
21
35
  }
22
36
 
23
37
  /**
@@ -89,6 +103,7 @@ class ScriptRunner {
89
103
  const context = createAdminScriptContext({
90
104
  executionId,
91
105
  integrationFactory: this.integrationFactory,
106
+ commands: this.scriptCommands,
92
107
  });
93
108
 
94
109
  let output;
@@ -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,11 @@ 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
+ scriptCommands: {},
76
+ });
76
77
  createScriptRunner.mockReturnValue(mockRunner);
77
78
  createAdminScriptCommands.mockReturnValue(mockCommands);
78
79
  createSchedulerAdapter.mockReturnValue(mockSchedulerAdapter);
@@ -174,6 +175,11 @@ describe('Admin Script Router', () => {
174
175
  mode: 'sync',
175
176
  })
176
177
  );
178
+ expect(createScriptRunner).toHaveBeenCalledWith({
179
+ scriptFactory: mockFactory,
180
+ integrationFactory: {},
181
+ scriptCommands: {},
182
+ });
177
183
  });
178
184
 
179
185
  it('should queue script for async execution', async () => {
@@ -0,0 +1,113 @@
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
+ // The command bundle is built from these factories; mock them so bootstrap
7
+ // doesn't need a configured database to construct the command groups.
8
+ jest.mock('@friggframework/core/application/commands/user-commands', () => ({
9
+ createUserCommands: jest.fn(() => ({ __kind: 'users' })),
10
+ }));
11
+ jest.mock(
12
+ '@friggframework/core/application/commands/credential-commands',
13
+ () => ({
14
+ createCredentialCommands: jest.fn(() => ({ __kind: 'credentials' })),
15
+ })
16
+ );
17
+ jest.mock('@friggframework/core/application/commands/entity-commands', () => ({
18
+ createEntityCommands: jest.fn(() => ({ __kind: 'entities' })),
19
+ }));
20
+ jest.mock(
21
+ '@friggframework/core/application/commands/integration-commands',
22
+ () => ({
23
+ createIntegrationCommands: jest.fn(() => ({ __kind: 'integrations' })),
24
+ })
25
+ );
26
+
27
+ const {
28
+ loadAppDefinition,
29
+ } = require('@friggframework/core/handlers/app-definition-loader');
30
+ const {
31
+ bootstrapAdminScripts,
32
+ _resetBootstrapForTests,
33
+ } = require('../bootstrap');
34
+
35
+ class TestScript extends AdminScriptBase {
36
+ static Definition = {
37
+ name: 'test-script',
38
+ version: '1.0.0',
39
+ description: 'Test script',
40
+ };
41
+ }
42
+
43
+ describe('bootstrapAdminScripts', () => {
44
+ beforeEach(() => {
45
+ _resetBootstrapForTests();
46
+ jest.clearAllMocks();
47
+ jest.spyOn(console, 'error').mockImplementation(() => {});
48
+ });
49
+
50
+ afterEach(() => {
51
+ console.error.mockRestore();
52
+ });
53
+
54
+ it('registers the app definition admin scripts into the returned factory', () => {
55
+ loadAppDefinition.mockReturnValue({ adminScripts: [TestScript] });
56
+
57
+ const { scriptFactory, integrationFactory } = bootstrapAdminScripts();
58
+
59
+ expect(scriptFactory).toBeInstanceOf(ScriptFactory);
60
+ expect(scriptFactory.has('test-script')).toBe(true);
61
+ expect(integrationFactory).toBeTruthy();
62
+ });
63
+
64
+ it('never throws and returns an empty factory when the app definition cannot load', () => {
65
+ loadAppDefinition.mockImplementation(() => {
66
+ throw new Error('cannot load app definition');
67
+ });
68
+
69
+ let result;
70
+ expect(() => {
71
+ result = bootstrapAdminScripts();
72
+ }).not.toThrow();
73
+
74
+ expect(result.scriptFactory).toBeInstanceOf(ScriptFactory);
75
+ expect(result.scriptFactory.size).toBe(0);
76
+ expect(result.integrationFactory).toBeTruthy();
77
+ expect(console.error).toHaveBeenCalled();
78
+ });
79
+
80
+ it('memoizes: repeated calls reuse the same factory and load the definition once', () => {
81
+ loadAppDefinition.mockReturnValue({ adminScripts: [TestScript] });
82
+
83
+ const first = bootstrapAdminScripts();
84
+ const second = bootstrapAdminScripts();
85
+
86
+ expect(second.scriptFactory).toBe(first.scriptFactory);
87
+ expect(second.integrationFactory).toBe(first.integrationFactory);
88
+ expect(second.scriptCommands).toBe(first.scriptCommands);
89
+ expect(loadAppDefinition).toHaveBeenCalledTimes(1);
90
+ });
91
+
92
+ it('builds the injectable command bundle (users, credentials, entities, integrations)', () => {
93
+ loadAppDefinition.mockReturnValue({ adminScripts: [] });
94
+
95
+ const { scriptCommands } = bootstrapAdminScripts();
96
+
97
+ expect(scriptCommands).toEqual({
98
+ users: { __kind: 'users' },
99
+ credentials: { __kind: 'credentials' },
100
+ entities: { __kind: 'entities' },
101
+ integrations: { __kind: 'integrations' },
102
+ });
103
+ });
104
+
105
+ it('tolerates an app definition with no adminScripts', () => {
106
+ loadAppDefinition.mockReturnValue({});
107
+
108
+ const { scriptFactory } = bootstrapAdminScripts();
109
+
110
+ expect(scriptFactory).toBeInstanceOf(ScriptFactory);
111
+ expect(scriptFactory.size).toBe(0);
112
+ });
113
+ });
@@ -0,0 +1,168 @@
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 mockScriptCommands;
16
+ let mockRunner;
17
+ let mockCommands;
18
+
19
+ beforeEach(() => {
20
+ mockScriptFactory = { id: 'script-factory' };
21
+ mockIntegrationFactory = { id: 'integration-factory' };
22
+ mockScriptCommands = { id: 'script-commands' };
23
+ mockRunner = { execute: jest.fn() };
24
+ mockCommands = {
25
+ completeAdminProcess: jest.fn().mockResolvedValue({}),
26
+ };
27
+
28
+ bootstrapAdminScripts.mockReturnValue({
29
+ scriptFactory: mockScriptFactory,
30
+ integrationFactory: mockIntegrationFactory,
31
+ scriptCommands: mockScriptCommands,
32
+ });
33
+ createScriptRunner.mockReturnValue(mockRunner);
34
+ createAdminScriptCommands.mockReturnValue(mockCommands);
35
+
36
+ jest.spyOn(console, 'log').mockImplementation(() => {});
37
+ jest.spyOn(console, 'error').mockImplementation(() => {});
38
+ });
39
+
40
+ afterEach(() => {
41
+ console.log.mockRestore();
42
+ console.error.mockRestore();
43
+ jest.clearAllMocks();
44
+ });
45
+
46
+ describe('EventBridge Scheduler direct invoke (no Records)', () => {
47
+ it('injects the bootstrapped factory into the runner and reports the result', async () => {
48
+ mockRunner.execute.mockResolvedValue({
49
+ status: 'COMPLETED',
50
+ executionId: 'exec-1',
51
+ });
52
+
53
+ const response = await handler({
54
+ scriptName: 'my-script',
55
+ trigger: 'SCHEDULED',
56
+ params: { foo: 'bar' },
57
+ });
58
+
59
+ expect(createScriptRunner).toHaveBeenCalledWith({
60
+ scriptFactory: mockScriptFactory,
61
+ integrationFactory: mockIntegrationFactory,
62
+ scriptCommands: mockScriptCommands,
63
+ });
64
+ expect(mockRunner.execute).toHaveBeenCalledWith(
65
+ 'my-script',
66
+ { foo: 'bar' },
67
+ expect.objectContaining({ trigger: 'SCHEDULED', mode: 'async' })
68
+ );
69
+
70
+ expect(response.statusCode).toBe(200);
71
+ const body = JSON.parse(response.body);
72
+ expect(body.processed).toBe(1);
73
+ expect(body.results[0]).toEqual({
74
+ scriptName: 'my-script',
75
+ status: 'COMPLETED',
76
+ executionId: 'exec-1',
77
+ });
78
+ });
79
+
80
+ it('marks the execution FAILED when the runner throws', async () => {
81
+ mockRunner.execute.mockRejectedValue(new Error('boom'));
82
+
83
+ const response = await handler({
84
+ scriptName: 'my-script',
85
+ executionId: 'exec-2',
86
+ trigger: 'SCHEDULED',
87
+ });
88
+
89
+ expect(response.statusCode).toBe(200);
90
+ const body = JSON.parse(response.body);
91
+ expect(body.results[0].status).toBe('FAILED');
92
+ expect(mockCommands.completeAdminProcess).toHaveBeenCalledWith(
93
+ 'exec-2',
94
+ expect.objectContaining({ state: 'FAILED' })
95
+ );
96
+ });
97
+ });
98
+
99
+ describe('SQS batch (Records[])', () => {
100
+ it('injects the bootstrapped factory and processes each record', async () => {
101
+ mockRunner.execute.mockResolvedValue({
102
+ status: 'COMPLETED',
103
+ executionId: 'exec-3',
104
+ });
105
+
106
+ const response = await handler({
107
+ Records: [
108
+ {
109
+ body: JSON.stringify({
110
+ scriptName: 'my-script',
111
+ executionId: 'exec-3',
112
+ params: {},
113
+ }),
114
+ },
115
+ ],
116
+ });
117
+
118
+ expect(createScriptRunner).toHaveBeenCalledWith({
119
+ scriptFactory: mockScriptFactory,
120
+ integrationFactory: mockIntegrationFactory,
121
+ scriptCommands: mockScriptCommands,
122
+ });
123
+ expect(mockRunner.execute).toHaveBeenCalledWith(
124
+ 'my-script',
125
+ {},
126
+ expect.objectContaining({
127
+ trigger: 'QUEUE',
128
+ executionId: 'exec-3',
129
+ })
130
+ );
131
+
132
+ expect(response.statusCode).toBe(200);
133
+ const body = JSON.parse(response.body);
134
+ expect(body.processed).toBe(1);
135
+ expect(body.results[0].status).toBe('COMPLETED');
136
+ });
137
+
138
+ it('isolates a bad record without dropping the rest of the batch', async () => {
139
+ mockRunner.execute.mockResolvedValue({
140
+ status: 'COMPLETED',
141
+ executionId: 'exec-4',
142
+ });
143
+
144
+ const response = await handler({
145
+ Records: [
146
+ // Missing scriptName -> runMessage throws before the runner
147
+ { body: JSON.stringify({ executionId: 'exec-bad' }) },
148
+ {
149
+ body: JSON.stringify({
150
+ scriptName: 'my-script',
151
+ executionId: 'exec-4',
152
+ params: {},
153
+ }),
154
+ },
155
+ ],
156
+ });
157
+
158
+ const body = JSON.parse(response.body);
159
+ expect(body.processed).toBe(2);
160
+ expect(body.results[0].status).toBe('FAILED');
161
+ expect(body.results[1].status).toBe('COMPLETED');
162
+ expect(mockCommands.completeAdminProcess).toHaveBeenCalledWith(
163
+ 'exec-bad',
164
+ expect.objectContaining({ state: 'FAILED' })
165
+ );
166
+ });
167
+ });
168
+ });
@@ -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,
@@ -24,16 +23,12 @@ const {
24
23
 
25
24
  const router = express.Router();
26
25
 
27
- // Apply auth middleware to all admin routes
26
+ // Apply auth middleware to all admin routes. Each handler then calls
27
+ // bootstrapAdminScripts() (memoized, so it runs once per process) to obtain the
28
+ // scriptFactory / integrationFactory / scriptCommands it needs — no global, and
29
+ // endpoints that don't touch scripts skip the work entirely.
28
30
  router.use(validateAdminApiKey);
29
31
 
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();
34
- next();
35
- });
36
-
37
32
  /**
38
33
  * Translate a thrown error into an HTTP response. Boom errors (thrown by the
39
34
  * schedule use cases) carry their own status code; anything else is an
@@ -42,7 +37,9 @@ router.use((_req, _res, next) => {
42
37
  */
43
38
  function sendError(res, error, fallbackMessage) {
44
39
  if (error.isBoom) {
45
- return res.status(error.output.statusCode).json({ error: error.message });
40
+ return res
41
+ .status(error.output.statusCode)
42
+ .json({ error: error.message });
46
43
  }
47
44
  console.error(fallbackMessage, error);
48
45
  return res.status(500).json({ error: fallbackMessage });
@@ -65,9 +62,10 @@ function buildAudit(req) {
65
62
 
66
63
  /**
67
64
  * Create schedule use case instances
65
+ * @param {ScriptFactory} scriptFactory - Registry injected from the request.
68
66
  * @private
69
67
  */
70
- function createScheduleUseCases() {
68
+ function createScheduleUseCases(scriptFactory) {
71
69
  const commands = createAdminScriptCommands();
72
70
 
73
71
  // The local adapter is in-memory only (schedules vanish on cold start), so it
@@ -88,7 +86,6 @@ function createScheduleUseCases() {
88
86
  scheduleGroupName: process.env.ADMIN_SCRIPT_SCHEDULE_GROUP,
89
87
  roleArn: process.env.SCHEDULER_ROLE_ARN,
90
88
  });
91
- const scriptFactory = getScriptFactory();
92
89
 
93
90
  return {
94
91
  getEffectiveSchedule: new GetEffectiveScheduleUseCase({
@@ -112,9 +109,9 @@ function createScheduleUseCases() {
112
109
  * GET /admin/scripts
113
110
  * List all registered scripts
114
111
  */
115
- router.get('/scripts', async (req, res) => {
112
+ router.get('/scripts', async (_req, res) => {
116
113
  try {
117
- const factory = getScriptFactory();
114
+ const { scriptFactory: factory } = bootstrapAdminScripts();
118
115
  const scripts = factory.getAll();
119
116
 
120
117
  res.json({
@@ -140,7 +137,7 @@ router.get('/scripts', async (req, res) => {
140
137
  router.get('/scripts/:scriptName', async (req, res) => {
141
138
  try {
142
139
  const { scriptName } = req.params;
143
- const factory = getScriptFactory();
140
+ const { scriptFactory: factory } = bootstrapAdminScripts();
144
141
 
145
142
  if (!factory.has(scriptName)) {
146
143
  return res.status(404).json({
@@ -175,7 +172,7 @@ router.post('/scripts/:scriptName/validate', async (req, res) => {
175
172
  try {
176
173
  const { scriptName } = req.params;
177
174
  const { params = {} } = req.body;
178
- const factory = getScriptFactory();
175
+ const { scriptFactory: factory } = bootstrapAdminScripts();
179
176
 
180
177
  if (!factory.has(scriptName)) {
181
178
  return res.status(404).json({
@@ -200,7 +197,11 @@ router.post('/scripts/:scriptName', async (req, res) => {
200
197
  try {
201
198
  const { scriptName } = req.params;
202
199
  const { params = {}, mode = 'async' } = req.body;
203
- const factory = getScriptFactory();
200
+ const {
201
+ scriptFactory: factory,
202
+ integrationFactory,
203
+ scriptCommands,
204
+ } = bootstrapAdminScripts();
204
205
 
205
206
  if (!factory.has(scriptName)) {
206
207
  return res.status(404).json({
@@ -237,8 +238,11 @@ router.post('/scripts/:scriptName', async (req, res) => {
237
238
  });
238
239
  }
239
240
 
240
- const { integrationFactory } = bootstrapAdminScripts();
241
- const runner = createScriptRunner({ integrationFactory });
241
+ const runner = createScriptRunner({
242
+ scriptFactory: factory,
243
+ integrationFactory,
244
+ scriptCommands,
245
+ });
242
246
  const result = await runner.execute(scriptName, params, {
243
247
  trigger: 'MANUAL',
244
248
  mode: 'sync',
@@ -355,7 +359,8 @@ router.get('/scripts/:scriptName/executions', async (req, res) => {
355
359
  router.get('/scripts/:scriptName/schedule', async (req, res) => {
356
360
  try {
357
361
  const { scriptName } = req.params;
358
- const { getEffectiveSchedule } = createScheduleUseCases();
362
+ const { scriptFactory } = bootstrapAdminScripts();
363
+ const { getEffectiveSchedule } = createScheduleUseCases(scriptFactory);
359
364
 
360
365
  const result = await getEffectiveSchedule.execute(scriptName);
361
366
 
@@ -377,7 +382,8 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => {
377
382
  try {
378
383
  const { scriptName } = req.params;
379
384
  const { enabled, cronExpression, timezone } = req.body;
380
- const { upsertSchedule } = createScheduleUseCases();
385
+ const { scriptFactory } = bootstrapAdminScripts();
386
+ const { upsertSchedule } = createScheduleUseCases(scriptFactory);
381
387
 
382
388
  const result = await upsertSchedule.execute(scriptName, {
383
389
  enabled,
@@ -407,7 +413,8 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => {
407
413
  router.delete('/scripts/:scriptName/schedule', async (req, res) => {
408
414
  try {
409
415
  const { scriptName } = req.params;
410
- const { deleteSchedule } = createScheduleUseCases();
416
+ const { scriptFactory } = bootstrapAdminScripts();
417
+ const { deleteSchedule } = createScheduleUseCases(scriptFactory);
411
418
 
412
419
  const result = await deleteSchedule.execute(scriptName);
413
420
 
@@ -1,18 +1,21 @@
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;
18
+ let scriptCommands = null;
16
19
  let integrationFactory = null;
17
20
 
18
21
  function registerScripts(factory, scriptClasses) {
@@ -43,22 +46,56 @@ function createIntegrationFactory() {
43
46
  }
44
47
 
45
48
  /**
46
- * @returns {{ integrationFactory: object }}
49
+ * Build the command bundle injected into every AdminScriptContext. Scripts
50
+ * interact with the database only through these Frigg commands — never through
51
+ * repositories directly. The require is lazy so the package keeps no load-time
52
+ * coupling to core's command/repository modules.
53
+ */
54
+ function buildScriptCommands() {
55
+ const {
56
+ createUserCommands,
57
+ } = require('@friggframework/core/application/commands/user-commands');
58
+ const {
59
+ createCredentialCommands,
60
+ } = require('@friggframework/core/application/commands/credential-commands');
61
+ const {
62
+ createEntityCommands,
63
+ } = require('@friggframework/core/application/commands/entity-commands');
64
+ const {
65
+ createIntegrationCommands,
66
+ } = require('@friggframework/core/application/commands/integration-commands');
67
+
68
+ return {
69
+ users: createUserCommands(),
70
+ credentials: createCredentialCommands(),
71
+ entities: createEntityCommands(),
72
+ // Class-agnostic reads (findIntegrationById, listIntegrations). Scripts
73
+ // that need class-scoped integration ops build their own commands.
74
+ integrations: createIntegrationCommands(),
75
+ };
76
+ }
77
+
78
+ /**
79
+ * @returns {{ scriptFactory: ScriptFactory, scriptCommands: object, integrationFactory: object }}
47
80
  */
48
81
  function bootstrapAdminScripts() {
49
82
  if (bootstrapped) {
50
- return { integrationFactory };
83
+ return { scriptFactory, scriptCommands, integrationFactory };
51
84
  }
52
85
  bootstrapped = true;
53
86
 
87
+ // Create the registry up front so consumers always get a (possibly empty)
88
+ // factory even when the app definition can't be loaded — mirrors the
89
+ // never-throw contract above.
90
+ scriptFactory = new ScriptFactory();
91
+
54
92
  try {
55
93
  const {
56
94
  loadAppDefinition,
57
95
  } = require('@friggframework/core/handlers/app-definition-loader');
58
96
  const { adminScripts = [] } = loadAppDefinition();
59
97
 
60
- const factory = getScriptFactory();
61
- registerScripts(factory, adminScripts);
98
+ registerScripts(scriptFactory, adminScripts);
62
99
  } catch (error) {
63
100
  console.error(
64
101
  '[admin-scripts] bootstrap: could not load app definition:',
@@ -66,13 +103,26 @@ function bootstrapAdminScripts() {
66
103
  );
67
104
  }
68
105
 
106
+ // Built in its own try so a command-layer failure never blocks script
107
+ // registration (and vice versa) — both honor the never-throw contract.
108
+ try {
109
+ scriptCommands = buildScriptCommands();
110
+ } catch (error) {
111
+ console.error(
112
+ '[admin-scripts] bootstrap: could not build command bundle:',
113
+ error.message
114
+ );
115
+ }
116
+
69
117
  integrationFactory = createIntegrationFactory();
70
- return { integrationFactory };
118
+ return { scriptFactory, scriptCommands, integrationFactory };
71
119
  }
72
120
 
73
121
  /** Test-only: reset memoized bootstrap state. */
74
122
  function _resetBootstrapForTests() {
75
123
  bootstrapped = false;
124
+ scriptFactory = null;
125
+ scriptCommands = null;
76
126
  integrationFactory = null;
77
127
  }
78
128
 
@@ -6,9 +6,24 @@ 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
+ * @param {Object} deps.scriptCommands - Frigg command bundle exposed to the script as context.commands.
20
+ * @returns {Promise<{ scriptName: string, status: string, executionId: string }>}
9
21
  * @private
10
22
  */
11
- async function runMessage(message, integrationFactory) {
23
+ async function runMessage(
24
+ message,
25
+ { scriptFactory, integrationFactory, scriptCommands }
26
+ ) {
12
27
  const { scriptName, executionId, trigger, params, parentExecutionId } =
13
28
  message;
14
29
 
@@ -22,7 +37,11 @@ async function runMessage(message, integrationFactory) {
22
37
  }`
23
38
  );
24
39
 
25
- const runner = createScriptRunner({ integrationFactory });
40
+ const runner = createScriptRunner({
41
+ scriptFactory,
42
+ integrationFactory,
43
+ scriptCommands,
44
+ });
26
45
  const result = await runner.execute(scriptName, params, {
27
46
  trigger: trigger || 'QUEUE',
28
47
  mode: 'async',
@@ -66,59 +85,58 @@ async function markFailed(executionId, error) {
66
85
  }
67
86
 
68
87
  /**
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.
88
+ * Handle an EventBridge Scheduler direct invoke: the event itself is a single
89
+ * execution message (no `Records` wrapper).
90
+ * @param {Object} event - The execution message.
91
+ * @param {{ scriptFactory: ScriptFactory, integrationFactory: object, scriptCommands: object }} deps
92
+ * @returns {Promise<{ statusCode: number, body: string }>}
93
+ * @private
79
94
  */
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
- }
95
+ async function handleScheduledInvoke(event, deps) {
96
+ try {
97
+ const result = await runMessage(event, deps);
98
+ console.log(
99
+ `Script completed: ${result.scriptName}, status: ${result.status}`
100
+ );
101
+ return {
102
+ statusCode: 200,
103
+ body: JSON.stringify({ processed: 1, results: [result] }),
104
+ };
105
+ } catch (error) {
106
+ console.error('Unexpected error processing scheduled invoke:', error);
107
+ await markFailed(event.executionId, error);
108
+ return {
109
+ statusCode: 200,
110
+ body: JSON.stringify({
111
+ processed: 1,
112
+ results: [
113
+ {
114
+ scriptName: event.scriptName || 'unknown',
115
+ status: 'FAILED',
116
+ error: error.message,
117
+ },
118
+ ],
119
+ }),
120
+ };
114
121
  }
122
+ }
115
123
 
124
+ /**
125
+ * Handle an SQS batch: each `event.Records[].body` is a JSON execution message
126
+ * (manual async executions and queueScript continuations). Failures are isolated
127
+ * per record so one bad message doesn't drop the rest of the batch.
128
+ * @param {Object} event - The SQS event with a `Records` array.
129
+ * @param {{ scriptFactory: ScriptFactory, integrationFactory: object, scriptCommands: object }} deps
130
+ * @returns {Promise<{ statusCode: number, body: string }>}
131
+ * @private
132
+ */
133
+ async function handleSqsBatch(event, deps) {
116
134
  const results = [];
117
135
  for (const record of event.Records) {
118
136
  let message = {};
119
137
  try {
120
138
  message = JSON.parse(record.body);
121
- const result = await runMessage(message, integrationFactory);
139
+ const result = await runMessage(message, deps);
122
140
  console.log(
123
141
  `Script completed: ${result.scriptName}, status: ${result.status}`
124
142
  );
@@ -143,4 +161,24 @@ async function handler(event) {
143
161
  };
144
162
  }
145
163
 
164
+ /**
165
+ * Admin Script Executor Lambda Handler
166
+ *
167
+ * Handles two invocation shapes:
168
+ * - SQS: `event.Records[]` — each record body is a JSON execution message
169
+ * (manual async executions and queueScript continuations).
170
+ * - EventBridge Scheduler direct invoke: the event itself is the message
171
+ * (`{ scriptName, trigger: 'SCHEDULED', params }`) with no `Records` wrapper.
172
+ *
173
+ * Thin adapter: parses the event and delegates to ScriptRunner, which owns
174
+ * execution tracking, error recording, and status updates.
175
+ */
176
+ async function handler(event) {
177
+ const deps = bootstrapAdminScripts();
178
+
179
+ return event.Records
180
+ ? handleSqsBatch(event, deps)
181
+ : handleScheduledInvoke(event, deps);
182
+ }
183
+
146
184
  module.exports = { handler };