@aloma.io/integration-sdk 3.8.68 → 3.8.70

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.
Files changed (44) hide show
  1. package/FINDINGS-PLAT-47.md +253 -0
  2. package/OPENAPI_TO_CONNECTOR.md +4 -1
  3. package/build/internal/fetcher/fetcher.d.mts +8 -0
  4. package/build/internal/fetcher/fetcher.mjs +47 -3
  5. package/build/openapi-to-connector.mjs +5 -2
  6. package/build/transform/index.d.mts +2 -2
  7. package/build/transform/index.mjs +493 -78
  8. package/package.json +3 -3
  9. package/src/internal/fetcher/fetcher.mts +52 -3
  10. package/src/openapi-to-connector.mts +6 -2
  11. package/src/transform/index.mts +530 -96
  12. package/test/fetcher-query-params.test.mjs +294 -0
  13. package/test/fixtures/transform/basic/expected.json +9 -0
  14. package/test/fixtures/transform/basic/src/controller/abstract.mts +5 -0
  15. package/test/fixtures/transform/basic/src/controller/index.mts +30 -0
  16. package/test/fixtures/transform/basic/tsconfig.json +1 -0
  17. package/test/fixtures/transform/interface-param/src/controller/abstract.mts +5 -0
  18. package/test/fixtures/transform/interface-param/src/controller/index.mts +12 -0
  19. package/test/fixtures/transform/interface-param/tsconfig.json +1 -0
  20. package/test/fixtures/transform/overloads/expected.json +6 -0
  21. package/test/fixtures/transform/overloads/src/controller/abstract.mts +5 -0
  22. package/test/fixtures/transform/overloads/src/controller/index.mts +11 -0
  23. package/test/fixtures/transform/overloads/tsconfig.json +1 -0
  24. package/test/fixtures/transform/tdd-stub/expected.json +8 -0
  25. package/test/fixtures/transform/tdd-stub/src/controller/abstract.mts +6 -0
  26. package/test/fixtures/transform/tdd-stub/src/controller/index.mts +204 -0
  27. package/test/fixtures/transform/tdd-stub/tsconfig.json +1 -0
  28. package/test/fixtures/transform/two-exports/src/controller/abstract.mts +5 -0
  29. package/test/fixtures/transform/two-exports/src/controller/index.mts +9 -0
  30. package/test/fixtures/transform/two-exports/tsconfig.json +1 -0
  31. package/test/fixtures/transform/type-error/src/controller/abstract.mts +5 -0
  32. package/test/fixtures/transform/type-error/src/controller/index.mts +7 -0
  33. package/test/fixtures/transform/type-error/tsconfig.json +1 -0
  34. package/test/fixtures/transform/typed/expected.json +8 -0
  35. package/test/fixtures/transform/typed/src/controller/abstract.mts +5 -0
  36. package/test/fixtures/transform/typed/src/controller/index.mts +13 -0
  37. package/test/fixtures/transform/typed/tsconfig.json +1 -0
  38. package/test/fixtures/transform/visibility/expected.json +9 -0
  39. package/test/fixtures/transform/visibility/src/controller/abstract.mts +5 -0
  40. package/test/fixtures/transform/visibility/src/controller/index.mts +19 -0
  41. package/test/fixtures/transform/visibility/tsconfig.json +1 -0
  42. package/test/openapi-to-connector.test.mts +7 -0
  43. package/test/transform.test.mjs +68 -0
  44. package/tools/mutation-run.mjs +23 -5
@@ -0,0 +1,294 @@
1
+ // PLAT-47: the Fetcher sends fetchOptions.params as the query string.
2
+ // Standalone script (no mocha). Global fetch is stubbed in this process, except one test that uses a local
3
+ // HTTP server on 127.0.0.1. No request leaves the test process.
4
+ import http from 'node:http';
5
+ import fs from 'node:fs';
6
+ import Fetcher from '../build/internal/fetcher/fetcher.mjs';
7
+ import {OAuth} from '../build/internal/fetcher/oauth-fetcher.mjs';
8
+
9
+ const colors = {green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', reset: '\x1b[0m', cyan: '\x1b[36m'};
10
+
11
+ let passed = 0;
12
+ let failed = 0;
13
+
14
+ async function test(name, fn) {
15
+ try {
16
+ console.log(`${colors.cyan}Running: ${name}${colors.reset}`);
17
+ await fn();
18
+ console.log(`${colors.green}✓ PASS: ${name}${colors.reset}\n`);
19
+ passed++;
20
+ } catch (error) {
21
+ console.log(`${colors.red}✗ FAIL: ${name}${colors.reset}`);
22
+ console.log(`${colors.red} Error: ${error.message}${colors.reset}\n`);
23
+ failed++;
24
+ }
25
+ }
26
+
27
+ function assert(condition, message) {
28
+ if (!condition) throw new Error(message || 'Assertion failed');
29
+ }
30
+
31
+ function assertEqual(actual, expected, message) {
32
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
33
+ throw new Error(`${message || 'not equal'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
34
+ }
35
+ }
36
+
37
+ const BASE = 'https://api.vendor.example.test';
38
+ // Fetcher collapses every run of slashes in baseUrl + url, so the scheme loses one slash (today's behaviour).
39
+ const RAW = 'https:/api.vendor.example.test';
40
+
41
+ // Replace global fetch with a recorder. `replies` is a list of statuses; the last one repeats.
42
+ function stubFetch(replies = [200]) {
43
+ const calls = [];
44
+ const realFetch = globalThis.fetch;
45
+ globalThis.fetch = async (url, init) => {
46
+ calls.push({url, init});
47
+ const status = replies[Math.min(calls.length - 1, replies.length - 1)];
48
+ return new Response(status === 204 ? null : JSON.stringify({ok: status < 400}), {
49
+ status,
50
+ headers: {'content-type': 'application/json'},
51
+ });
52
+ };
53
+ return {calls, restore: () => (globalThis.fetch = realFetch)};
54
+ }
55
+
56
+ async function send(path, options, fetcherOptions = {}, replies) {
57
+ const stub = stubFetch(replies);
58
+ try {
59
+ const fetcher = new Fetcher({baseUrl: BASE, retry: 1, ...fetcherOptions});
60
+ let error;
61
+ try {
62
+ await fetcher.fetch(path, options);
63
+ } catch (e) {
64
+ error = e;
65
+ }
66
+ return {calls: stub.calls, error};
67
+ } finally {
68
+ stub.restore();
69
+ }
70
+ }
71
+
72
+ function initWithoutSignal(init) {
73
+ const {signal, ...rest} = init;
74
+ return rest;
75
+ }
76
+
77
+ // --- Rule 1: params become the query string ---
78
+
79
+ await test('numbers, strings and booleans are stringified into the query string', async () => {
80
+ const {calls} = await send('/crm/v3/objects/contacts', {
81
+ method: 'GET',
82
+ params: {limit: 10, after: 'abc', archived: false},
83
+ });
84
+ assertEqual(calls.length, 1, 'one request');
85
+ assertEqual(calls[0].url, `${RAW}/crm/v3/objects/contacts?limit=10&after=abc&archived=false`, 'url');
86
+ });
87
+
88
+ await test('query parameters already in the url are kept', async () => {
89
+ const {calls} = await send('/items?foo=1', {method: 'GET', params: {bar: 2}});
90
+ assertEqual(calls[0].url, `${RAW}/items?foo=1&bar=2`, 'url');
91
+ });
92
+
93
+ await test('undefined and null values are skipped', async () => {
94
+ const {calls} = await send('/items', {method: 'GET', params: {a: undefined, b: null, c: 'x', d: 0, e: ''}});
95
+ assertEqual(calls[0].url, `${RAW}/items?c=x&d=0&e=`, 'url');
96
+ });
97
+
98
+ await test('arrays become repeated keys', async () => {
99
+ const {calls} = await send('/crm/v3/objects/contacts', {
100
+ method: 'GET',
101
+ params: {properties: ['firstname', 'lastname'], limit: 1},
102
+ });
103
+ assertEqual(calls[0].url, `${RAW}/crm/v3/objects/contacts?properties=firstname&properties=lastname&limit=1`, 'url');
104
+ });
105
+
106
+ await test('undefined and null array items are skipped, other items stringified', async () => {
107
+ const {calls} = await send('/items', {method: 'GET', params: {ids: [1, undefined, null, true, 'x']}});
108
+ assertEqual(calls[0].url, `${RAW}/items?ids=1&ids=true&ids=x`, 'url');
109
+ });
110
+
111
+ await test('nested objects are refused with a clear error and nothing is sent', async () => {
112
+ const started = Date.now();
113
+ const {calls, error} = await send('/items', {method: 'GET', params: {filter: {a: 1}}}, {retry: 3});
114
+ assert(error, 'fetch must reject');
115
+ assert(/params\.filter/.test(error.message) && /object/.test(error.message), `error message: ${error.message}`);
116
+ assertEqual(calls.length, 0, 'no request sent');
117
+ assert(Date.now() - started < 400, 'refused at once, not retried');
118
+ });
119
+
120
+ await test('objects inside arrays are refused too', async () => {
121
+ const {calls, error} = await send('/items', {method: 'GET', params: {ids: [{a: 1}]}});
122
+ assert(error && /params\.ids/.test(error.message), `error: ${error && error.message}`);
123
+ assertEqual(calls.length, 0, 'no request sent');
124
+ });
125
+
126
+ await test('values are encoded by URLSearchParams and a "//" inside a value is not collapsed', async () => {
127
+ const value = 'a b&c=d/é https://x.test//y';
128
+ const {calls} = await send('/items', {method: 'GET', params: {q: value}});
129
+ assertEqual(calls[0].url, `${RAW}/items?${new URLSearchParams({q: value})}`, 'url');
130
+ assertEqual(new URL(calls[0].url).searchParams.get('q'), value, 'decoded value round-trips');
131
+ });
132
+
133
+ await test('a URLSearchParams instance is sent as it is', async () => {
134
+ const {calls} = await send('/items', {
135
+ method: 'GET',
136
+ params: new URLSearchParams([
137
+ ['a', '1'],
138
+ ['a', '2'],
139
+ ]),
140
+ });
141
+ assertEqual(calls[0].url, `${RAW}/items?a=1&a=2`, 'url');
142
+ });
143
+
144
+ await test('without baseUrl the query is appended to the url as given', async () => {
145
+ const {calls} = await send('https://other.example.test/x', {method: 'GET', params: {a: 1}}, {baseUrl: undefined});
146
+ assertEqual(calls[0].url, 'https://other.example.test/x?a=1', 'url');
147
+ });
148
+
149
+ // --- Rule 2: nothing else changes ---
150
+
151
+ await test('params is not passed on to global fetch; headers, body and method are unchanged', async () => {
152
+ const options = {method: 'POST', headers: {'X-Custom': '1'}, body: {name: 'n'}};
153
+ const plain = await send('/items', {...options});
154
+ const withParams = await send('/items', {...options, params: {a: 1}});
155
+ assert(!('params' in withParams.calls[0].init), 'params must not reach native fetch');
156
+ assertEqual(initWithoutSignal(withParams.calls[0].init), initWithoutSignal(plain.calls[0].init), 'init');
157
+ });
158
+
159
+ await test('the caller options and params objects are not mutated', async () => {
160
+ const params = {a: 1, list: ['x', 'y'], skip: undefined};
161
+ const options = {method: 'GET', params};
162
+ await send('/items', options);
163
+ assertEqual(Object.keys(options), ['method', 'params'], 'options keys');
164
+ assert(options.params === params, 'same params object');
165
+ assertEqual(Object.keys(params), ['a', 'list', 'skip'], 'params keys');
166
+ assertEqual(params.list, ['x', 'y'], 'array untouched');
167
+ });
168
+
169
+ await test('customize runs as before and can change url and params', async () => {
170
+ let seen;
171
+ const {calls} = await send(
172
+ '/items',
173
+ {method: 'GET', params: {a: 1}},
174
+ {
175
+ customize: (o) => {
176
+ seen = {url: o.url, params: {...o.params}};
177
+ o.url = '/v2' + o.url;
178
+ o.params = {...o.params, key: 'k'};
179
+ o.headers = {...o.headers, Authorization: 'Bearer t'};
180
+ },
181
+ }
182
+ );
183
+ assertEqual(seen, {url: '/items', params: {a: 1}}, 'customize input');
184
+ assertEqual(calls[0].url, `${RAW}/v2/items?a=1&key=k`, 'url');
185
+ assertEqual(calls[0].init.headers.Authorization, 'Bearer t', 'customize header');
186
+ });
187
+
188
+ await test('a retried request sends the same url with the query string exactly once', async () => {
189
+ const {calls, error} = await send(
190
+ '/items',
191
+ {method: 'GET', params: {a: 1, list: ['x', 'y']}},
192
+ {retry: 2},
193
+ [500, 200]
194
+ );
195
+ assert(!error, `no error expected, got ${error && error.message}`);
196
+ assertEqual(calls.length, 2, 'first try + one retry');
197
+ assertEqual(calls[0].url, `${RAW}/items?a=1&list=x&list=y`, 'first url');
198
+ assertEqual(calls[1].url, calls[0].url, 'retry url');
199
+ assert(!('params' in calls[1].init), 'params must not reach native fetch on retry');
200
+ });
201
+
202
+ await test('a failed attempt logs the url without its query string', async () => {
203
+ const logged = [];
204
+ const realLog = console.log;
205
+ console.log = (...args) => logged.push(args.map(String).join(' '));
206
+ let calls;
207
+ try {
208
+ ({calls} = await send('/items?id=user42', {method: 'GET', params: {email: 'person@example.test'}}, {retry: 1}, [
209
+ 500,
210
+ ]));
211
+ } finally {
212
+ console.log = realLog;
213
+ }
214
+ assertEqual(calls[0].url, `${RAW}/items?id=user42&email=person%40example.test`, 'the query is sent');
215
+ assert(logged.length > 0, 'the failure is logged');
216
+ assert(
217
+ !logged.some((l) => l.includes('person') || l.includes('user42')),
218
+ `query value leaked into the log: ${logged.join(' | ')}`
219
+ );
220
+ assert(
221
+ logged.some((l) => l.includes(`${RAW}/items`)),
222
+ 'the path is still logged'
223
+ );
224
+ });
225
+
226
+ // --- Rule 3: no params gives exactly today's URL ---
227
+
228
+ await test('no params, empty params or all-undefined params give exactly the old url', async () => {
229
+ for (const options of [
230
+ {method: 'GET'},
231
+ {method: 'GET', params: {}},
232
+ {method: 'GET', params: {a: undefined, b: null}},
233
+ {method: 'GET', params: undefined},
234
+ ]) {
235
+ const {calls} = await send('/items/1', options);
236
+ assertEqual(calls[0].url, `${RAW}/items/1`, `url for ${JSON.stringify(options)}`);
237
+ }
238
+ const {calls} = await send('/items/1?x=1', {method: 'GET', params: {}});
239
+ assertEqual(calls[0].url, `${RAW}/items/1?x=1`, 'existing query alone is untouched');
240
+ });
241
+
242
+ // --- OAuth fetcher inherits the behaviour ---
243
+
244
+ await test('the OAuth fetcher sends the query string and its Bearer token', async () => {
245
+ const stub = stubFetch();
246
+ try {
247
+ const client = new OAuth({access_token: 'tok'}).getClient({baseUrl: BASE, retry: 1});
248
+ await client.fetch('/crm/v3/objects/contacts', {method: 'GET', params: {properties: ['a', 'b'], archived: true}});
249
+ assertEqual(stub.calls[0].url, `${RAW}/crm/v3/objects/contacts?properties=a&properties=b&archived=true`, 'url');
250
+ assertEqual(stub.calls[0].init.headers.Authorization, 'Bearer tok', 'auth header');
251
+ assert(!('params' in stub.calls[0].init), 'params must not reach native fetch');
252
+ } finally {
253
+ stub.restore();
254
+ }
255
+ });
256
+
257
+ // --- End to end over a local socket ---
258
+
259
+ await test('a local HTTP server receives the query string through the real global fetch', async () => {
260
+ const seen = [];
261
+ const server = http.createServer((req, res) => {
262
+ seen.push(req.url);
263
+ res.writeHead(200, {'content-type': 'application/json'});
264
+ res.end('{"ok":true}');
265
+ });
266
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
267
+ try {
268
+ const {port} = server.address();
269
+ const fetcher = new Fetcher({baseUrl: `http://127.0.0.1:${port}`, retry: 1});
270
+ const ret = await fetcher.fetch('/crm/v3/objects/contacts', {
271
+ method: 'GET',
272
+ params: {limit: 2, properties: ['a', 'b']},
273
+ });
274
+ assertEqual(ret, {ok: true}, 'response');
275
+ assertEqual(seen, ['/crm/v3/objects/contacts?limit=2&properties=a&properties=b'], 'request line');
276
+ } finally {
277
+ await new Promise((resolve) => server.close(resolve));
278
+ }
279
+ });
280
+
281
+ // --- Typing for hand-written connectors ---
282
+
283
+ await test('params is part of the typed fetch options', async () => {
284
+ const dts = fs.readFileSync(new URL('../build/internal/fetcher/fetcher.d.mts', import.meta.url), 'utf8');
285
+ assert(/params\?:/.test(dts), 'fetcher.d.mts declares params?');
286
+ });
287
+
288
+ console.log(`\n${colors.yellow}--- Fetcher query params test results ---${colors.reset}`);
289
+ console.log(`${colors.green}Passed: ${passed}${colors.reset}`);
290
+ console.log(`${colors.red}Failed: ${failed}${colors.reset}`);
291
+
292
+ if (failed > 0) {
293
+ process.exit(1);
294
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "text": "\n/**\n * greet somebody\n *\n * @namespace demo\n * @example usage\n```javascript\ngreet({name: 'Ann'})\n```\n **/ \ndeclare function greet({name; times = 2; loud = false; label = 'x'; neg = -1; nothing}: {\n/**\nwho to greet, e.g. Ann\n */\n name: any; \n/**\nhow often\n */\n times: any; \n/**\n\n */\n loud: any; \n/**\n\n */\n label: any; \n/**\n\n */\n neg: any; \n/**\n\n */\n nothing: any}): { ok: boolean; greeting: any; };\n \n/**\n * no params\n *\n * \n * \n **/ \ndeclare function ping(): { ok: boolean; };\n \n/**\n * \n *\n * \n * \n **/ \ndeclare function list(): string[];\n \n/**\n * \n *\n * \n * \n **/ \ndeclare function nothing(): void;\n ",
3
+ "methods": [
4
+ "greet",
5
+ "ping",
6
+ "list",
7
+ "nothing"
8
+ ]
9
+ }
@@ -0,0 +1,5 @@
1
+ // stands in for AbstractController from @aloma.io/integration-sdk
2
+ export class AbstractController {
3
+ protected config: any = {};
4
+ async inheritedPublic(): Promise<void> {}
5
+ }
@@ -0,0 +1,30 @@
1
+ import {AbstractController} from './abstract.mjs';
2
+
3
+ export default class Controller extends AbstractController {
4
+ /**
5
+ * greet somebody
6
+ * @param name - who to greet, e.g. Ann
7
+ * @param times - how often
8
+ * @param opts - extra \@options
9
+ * @namespace demo
10
+ *
11
+ * @example
12
+ * ```javascript
13
+ * greet({name: 'Ann'})
14
+ * ```
15
+ */
16
+ async greet({name, times = 2, loud = false, label = 'x', neg = -1, nothing = null}, other = 1) {
17
+ return {ok: true, greeting: name + times};
18
+ }
19
+
20
+ /** no params */
21
+ async ping() {
22
+ return {ok: true};
23
+ }
24
+
25
+ async list(): Promise<string[]> {
26
+ return [];
27
+ }
28
+
29
+ async nothing(): Promise<void> {}
30
+ }
@@ -0,0 +1 @@
1
+ {"compilerOptions":{"target":"es2022","module":"node16","moduleResolution":"node16","skipLibCheck":true,"strict":true,"noImplicitAny":false,"experimentalDecorators":true,"esModuleInterop":true},"include":["src/**/*"]}
@@ -0,0 +1,5 @@
1
+ // stands in for AbstractController from @aloma.io/integration-sdk
2
+ export class AbstractController {
3
+ protected config: any = {};
4
+ async inheritedPublic(): Promise<void> {}
5
+ }
@@ -0,0 +1,12 @@
1
+ import {AbstractController} from './abstract.mjs';
2
+
3
+ interface Opts {
4
+ id: string;
5
+ n?: number;
6
+ }
7
+
8
+ export default class Controller extends AbstractController {
9
+ async viaInterface({id, n}: Opts): Promise<{id: string; n: number | undefined}> {
10
+ return {id, n};
11
+ }
12
+ }
@@ -0,0 +1 @@
1
+ {"compilerOptions":{"target":"es2022","module":"node16","moduleResolution":"node16","skipLibCheck":true,"strict":true,"noImplicitAny":false,"experimentalDecorators":true,"esModuleInterop":true},"include":["src/**/*"]}
@@ -0,0 +1,6 @@
1
+ {
2
+ "text": "\n/**\n * first form\n *\n * \n * \n **/ \ndeclare function over({a}: {\n/**\n\n */\n a: string}): string;\n \n\n/**\n * second form\n *\n * \n * \n **/ \ndeclare function over({b}: {\n/**\n\n */\n b: number}): number;\n ",
3
+ "methods": [
4
+ "over"
5
+ ]
6
+ }
@@ -0,0 +1,5 @@
1
+ // stands in for AbstractController from @aloma.io/integration-sdk
2
+ export class AbstractController {
3
+ protected config: any = {};
4
+ async inheritedPublic(): Promise<void> {}
5
+ }
@@ -0,0 +1,11 @@
1
+ import {AbstractController} from './abstract.mjs';
2
+
3
+ export default class Controller extends AbstractController {
4
+ /** first form */
5
+ async over({a}: {a: string}): Promise<string>;
6
+ /** second form */
7
+ async over({b}: {b: number}): Promise<number>;
8
+ async over(args: any): Promise<any> {
9
+ return args;
10
+ }
11
+ }
@@ -0,0 +1 @@
1
+ {"compilerOptions":{"target":"es2022","module":"node16","moduleResolution":"node16","skipLibCheck":true,"strict":true,"noImplicitAny":false,"experimentalDecorators":true,"esModuleInterop":true},"include":["src/**/*"]}
@@ -0,0 +1,8 @@
1
+ {
2
+ "text": "\n/**\n * Returns recorded calls scoped to the current task.\n *\n * \n * @example usage\n```javascript\nconst log = await connectors.testing.getCallLog({})\n// log.calls = [{method, args, timestamp}, ...] for THIS task only\n```\n **/ \ndeclare function getCallLog(): { calls: CallRecord[]; count: number; };\n \n/**\n * Resets the call log for the current task only.\n *\n * \n * @example usage\n```javascript\nawait connectors.testing.clearCallLog({})\n```\n **/ \ndeclare function clearCallLog(): { cleared: boolean; };\n \n/**\n * Updates the response map for the current task at runtime.\n *\n * \n * @example usage\n```javascript\nawait connectors.testing.setResponses({\n responses: { \"someConnector.create\": { id: \"456\" } }\n})\n```\n **/ \ndeclare function setResponses(): { updated: boolean; responseCount: number; };\n ",
3
+ "methods": [
4
+ "getCallLog",
5
+ "clearCallLog",
6
+ "setResponses"
7
+ ]
8
+ }
@@ -0,0 +1,6 @@
1
+ // stands in for AbstractController from @aloma.io/integration-sdk
2
+ export class AbstractController {
3
+ protected config: any = {};
4
+ async inheritedPublic(): Promise<void> {}
5
+ }
6
+ export {};
@@ -0,0 +1,204 @@
1
+ import { AbstractController } from "./abstract.mjs";
2
+
3
+ interface CallRecord {
4
+ method: string;
5
+ args: any;
6
+ timestamp: string;
7
+ }
8
+
9
+ /**
10
+ * Per-task scoped mock connector for ALOMA test workflows.
11
+ *
12
+ * State (responseMap, callLog) is keyed by ctx.taskId so concurrent
13
+ * test tasks against the same Testing pod do not contaminate each
14
+ * other's stubbed responses or recorded calls.
15
+ *
16
+ * Requires @aloma.io/integration-sdk ^3.8.67, which forwards
17
+ * { taskId, namespace, sticky } as the second arg to controller
18
+ * method handlers and to the __default fallback.
19
+ *
20
+ * Backward compatibility: when ctx is missing (legacy invocation or
21
+ * non-task call from admin UI), state falls into the "__no_ctx__"
22
+ * bucket so the connector does not crash. Isolation is NOT guaranteed
23
+ * in that mode — it is degraded compatibility only.
24
+ */
25
+ export default class Controller extends AbstractController {
26
+ // Per-task scoped state — Maps keyed by taskId (insertion order = LRU order)
27
+ private responseMapByTask: Map<string, Record<string, any>> = new Map();
28
+ private callLogByTask: Map<string, CallRecord[]> = new Map();
29
+ private recording: boolean = true;
30
+ private defaultResponses: Record<string, any> = {};
31
+ // Memory cap (LRU). Configurable via config.maxTasks; default 1000.
32
+ private maxTasks: number = 1000;
33
+
34
+ protected async start() {
35
+ this.loadConfig();
36
+ }
37
+
38
+ private loadConfig() {
39
+ const config = this.config;
40
+
41
+ // Parse default responses from connector config — these seed each
42
+ // task's bucket when first touched.
43
+ if (config.responses) {
44
+ try {
45
+ this.defaultResponses =
46
+ typeof config.responses === "string"
47
+ ? JSON.parse(config.responses)
48
+ : config.responses;
49
+ } catch (e) {
50
+ this.defaultResponses = {};
51
+ }
52
+ } else {
53
+ this.defaultResponses = {};
54
+ }
55
+
56
+ this.recording = config.recordCalls !== "false";
57
+
58
+ // Allow config-driven override of LRU cap. Default 1000 if not set
59
+ // or not a positive number.
60
+ const configured = Number(config.maxTasks);
61
+ if (Number.isFinite(configured) && configured > 0) {
62
+ this.maxTasks = Math.floor(configured);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Resolve the bucket key for the current invocation. When ctx is
68
+ * absent or lacks taskId, falls back to a single shared bucket so
69
+ * legacy / hand-rolled callers do not crash. Isolation is not
70
+ * guaranteed in that mode.
71
+ */
72
+ private bucketKey(ctx?: { taskId?: string }): string {
73
+ return ctx && ctx.taskId ? ctx.taskId : "__no_ctx__";
74
+ }
75
+
76
+ /**
77
+ * LRU touch — ensures the bucket exists for the given key, evicting
78
+ * the least-recently-used entry if the cap would be exceeded. Map
79
+ * iteration order is insertion order; reinserting promotes a key to
80
+ * "most recently used" position.
81
+ */
82
+ private touchTask(key: string) {
83
+ // If not present and at cap, evict the oldest (insertion order)
84
+ if (!this.responseMapByTask.has(key)) {
85
+ if (this.responseMapByTask.size >= this.maxTasks) {
86
+ const firstKey = this.responseMapByTask.keys().next().value as
87
+ | string
88
+ | undefined;
89
+ if (firstKey !== undefined) {
90
+ this.responseMapByTask.delete(firstKey);
91
+ this.callLogByTask.delete(firstKey);
92
+ }
93
+ }
94
+ this.responseMapByTask.set(key, { ...this.defaultResponses });
95
+ this.callLogByTask.set(key, []);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Catch-all handler — registered as __default by the SDK via the
101
+ * fallback override below. Looks up the response in the task-scoped
102
+ * map, records the call in the task-scoped log, and returns the
103
+ * configured response or a default stub acknowledgement.
104
+ */
105
+ private async handleStubCall(
106
+ arg: any,
107
+ ctx?: { taskId?: string },
108
+ ): Promise<any> {
109
+ const key = this.bucketKey(ctx);
110
+ this.touchTask(key);
111
+
112
+ const method = Array.isArray(arg?.__method)
113
+ ? arg.__method.join(".")
114
+ : arg?.__method || "unknown";
115
+
116
+ if (this.recording) {
117
+ const log = this.callLogByTask.get(key)!;
118
+ log.push({
119
+ method,
120
+ args: { ...arg, __method: undefined },
121
+ timestamp: new Date().toISOString(),
122
+ });
123
+ }
124
+
125
+ const responseMap = this.responseMapByTask.get(key)!;
126
+ if (responseMap[method] !== undefined) {
127
+ return responseMap[method];
128
+ }
129
+ if (responseMap["default"] !== undefined) {
130
+ return responseMap["default"];
131
+ }
132
+
133
+ return { stubbed: true, method };
134
+ }
135
+
136
+ /**
137
+ * SDK override hook — invoked by AbstractController.__default with
138
+ * the per-invocation ctx (taskId, namespace, sticky) as second arg.
139
+ */
140
+ protected fallback(arg: any, ctx?: { taskId?: string }): Promise<any> {
141
+ return this.handleStubCall(arg, ctx);
142
+ }
143
+
144
+ /**
145
+ * Returns recorded calls scoped to the current task.
146
+ * @example
147
+ * ```javascript
148
+ * const log = await connectors.testing.getCallLog({})
149
+ * // log.calls = [{method, args, timestamp}, ...] for THIS task only
150
+ * ```
151
+ */
152
+ async getCallLog(_args: any, ctx?: { taskId?: string }) {
153
+ const key = this.bucketKey(ctx);
154
+ const calls = this.callLogByTask.get(key) || [];
155
+ return { calls: [...calls], count: calls.length };
156
+ }
157
+
158
+ /**
159
+ * Resets the call log for the current task only.
160
+ * @example
161
+ * ```javascript
162
+ * await connectors.testing.clearCallLog({})
163
+ * ```
164
+ */
165
+ async clearCallLog(_args: any, ctx?: { taskId?: string }) {
166
+ const key = this.bucketKey(ctx);
167
+ if (this.callLogByTask.has(key)) {
168
+ this.callLogByTask.set(key, []);
169
+ }
170
+ return { cleared: true };
171
+ }
172
+
173
+ /**
174
+ * Updates the response map for the current task at runtime.
175
+ * @example
176
+ * ```javascript
177
+ * await connectors.testing.setResponses({
178
+ * responses: { "someConnector.create": { id: "456" } }
179
+ * })
180
+ * ```
181
+ */
182
+ async setResponses(args: any, ctx?: { taskId?: string }) {
183
+ const key = this.bucketKey(ctx);
184
+ this.touchTask(key);
185
+
186
+ if (args?.responses) {
187
+ try {
188
+ const newResponses =
189
+ typeof args.responses === "string"
190
+ ? JSON.parse(args.responses)
191
+ : args.responses;
192
+ const existing = this.responseMapByTask.get(key)!;
193
+ this.responseMapByTask.set(key, { ...existing, ...newResponses });
194
+ } catch (e) {
195
+ // ignore parse errors — leave existing map untouched
196
+ }
197
+ }
198
+
199
+ return {
200
+ updated: true,
201
+ responseCount: Object.keys(this.responseMapByTask.get(key)!).length,
202
+ };
203
+ }
204
+ }
@@ -0,0 +1 @@
1
+ {"compilerOptions":{"target":"es2022","module":"node16","moduleResolution":"node16","skipLibCheck":true,"strict":true,"noImplicitAny":false,"experimentalDecorators":true,"esModuleInterop":true},"include":["src/**/*"]}
@@ -0,0 +1,5 @@
1
+ // stands in for AbstractController from @aloma.io/integration-sdk
2
+ export class AbstractController {
3
+ protected config: any = {};
4
+ async inheritedPublic(): Promise<void> {}
5
+ }
@@ -0,0 +1,9 @@
1
+ import {AbstractController} from './abstract.mjs';
2
+
3
+ export interface Extra {
4
+ a: string;
5
+ }
6
+
7
+ export default class Controller extends AbstractController {
8
+ async one() {}
9
+ }
@@ -0,0 +1 @@
1
+ {"compilerOptions":{"target":"es2022","module":"node16","moduleResolution":"node16","skipLibCheck":true,"strict":true,"noImplicitAny":false,"experimentalDecorators":true,"esModuleInterop":true},"include":["src/**/*"]}
@@ -0,0 +1,5 @@
1
+ // stands in for AbstractController from @aloma.io/integration-sdk
2
+ export class AbstractController {
3
+ protected config: any = {};
4
+ async inheritedPublic(): Promise<void> {}
5
+ }
@@ -0,0 +1,7 @@
1
+ import {AbstractController} from './abstract.mjs';
2
+
3
+ export default class Controller extends AbstractController {
4
+ async one(): Promise<number> {
5
+ return 'not a number';
6
+ }
7
+ }
@@ -0,0 +1 @@
1
+ {"compilerOptions":{"target":"es2022","module":"node16","moduleResolution":"node16","skipLibCheck":true,"strict":true,"noImplicitAny":false,"experimentalDecorators":true,"esModuleInterop":true},"include":["src/**/*"]}