@friggframework/core 2.0.0-next.103 → 2.0.0-next.105
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin-scripts/repositories/admin-script-execution-repository-interface.js +7 -2
- package/admin-scripts/repositories/admin-script-execution-repository-mongo.js +16 -6
- package/admin-scripts/repositories/admin-script-execution-repository-postgres.js +16 -6
- package/application/commands/admin-script-commands.js +2 -1
- package/application/commands/credential-commands.js +17 -0
- package/application/commands/integration-commands.js +14 -1
- package/application/commands/integration-mapping-commands.js +25 -0
- package/application/commands/report-commands.js +188 -0
- package/artifacts/repositories/artifact-repository-factory.js +19 -0
- package/artifacts/repositories/artifact-repository-interface.js +27 -0
- package/artifacts/repositories/artifact-repository-local.js +42 -0
- package/artifacts/repositories/artifact-repository-s3.js +61 -0
- package/credential/repositories/credential-active-type.js +32 -0
- package/credential/repositories/credential-repository-documentdb.js +51 -0
- package/credential/repositories/credential-repository-interface.js +15 -0
- package/credential/repositories/credential-repository-mongo.js +25 -0
- package/credential/repositories/credential-repository-postgres.js +25 -0
- package/database/documentdb-utils.js +56 -0
- package/database/use-cases/resolve-migration-via-worker-use-case.js +49 -0
- package/database/utils/prisma-runner.js +16 -2
- package/handlers/app-definition-loader.js +3 -2
- package/handlers/routers/db-migration.js +36 -18
- package/handlers/workers/db-migration.js +75 -0
- package/index.js +8 -4
- package/integrations/repositories/integration-mapping-repository-documentdb.js +23 -0
- package/integrations/repositories/integration-mapping-repository-interface.js +14 -0
- package/integrations/repositories/integration-mapping-repository-mongo.js +22 -0
- package/integrations/repositories/integration-mapping-repository-postgres.js +28 -0
- package/integrations/repositories/integration-repository-documentdb.js +38 -0
- package/integrations/repositories/integration-repository-interface.js +16 -0
- package/integrations/repositories/integration-repository-mongo.js +36 -0
- package/integrations/repositories/integration-repository-postgres.js +40 -0
- package/integrations/repositories/report-id.js +13 -0
- package/package.json +7 -5
- package/reporting/README.md +109 -48
- package/reporting/builtin-reports.js +6 -0
- package/reporting/index.js +9 -13
- package/reporting/report-base.js +49 -0
- package/reporting/{use-cases/list-integrations-report.js → reports/integrations-report.js} +69 -37
- package/handlers/routers/reporting.js +0 -9
- package/reporting/reporting-router.js +0 -84
- package/reporting/repositories/reporting-repository-documentdb.js +0 -127
- package/reporting/repositories/reporting-repository-factory.js +0 -35
- package/reporting/repositories/reporting-repository-interface.js +0 -16
- package/reporting/repositories/reporting-repository-mongo.js +0 -54
- package/reporting/repositories/reporting-repository-postgres.js +0 -70
- package/reporting/use-cases/index.js +0 -6
|
@@ -2,6 +2,7 @@ const { prisma } = require('../../database/prisma');
|
|
|
2
2
|
const {
|
|
3
3
|
CredentialRepositoryInterface,
|
|
4
4
|
} = require('./credential-repository-interface');
|
|
5
|
+
const { tallyActiveCredentialsByType } = require('./credential-active-type');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* MongoDB Credential Repository Adapter
|
|
@@ -231,6 +232,30 @@ class CredentialRepositoryMongo extends CredentialRepositoryInterface {
|
|
|
231
232
|
};
|
|
232
233
|
}
|
|
233
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Count credentials active since a timestamp, grouped by integration type.
|
|
237
|
+
*
|
|
238
|
+
* @param {Object} params
|
|
239
|
+
* @param {Date} [params.since] - Lower bound on updatedAt
|
|
240
|
+
* @returns {Promise<Array<{ integrationType: string, count: number }>>}
|
|
241
|
+
*/
|
|
242
|
+
async countActiveByType({ since } = {}) {
|
|
243
|
+
const where = {};
|
|
244
|
+
if (since) where.updatedAt = { gte: since };
|
|
245
|
+
|
|
246
|
+
// Type lives on the related Entity.moduleName, not on Credential. Select
|
|
247
|
+
// only that field (+ id) so the encrypted `data` JSON is never decrypted.
|
|
248
|
+
const credentials = await this.prisma.credential.findMany({
|
|
249
|
+
where,
|
|
250
|
+
select: {
|
|
251
|
+
id: true,
|
|
252
|
+
entities: { select: { moduleName: true } },
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return tallyActiveCredentialsByType(credentials);
|
|
257
|
+
}
|
|
258
|
+
|
|
234
259
|
/**
|
|
235
260
|
* Convert identifiers to Prisma where clause
|
|
236
261
|
* @private
|
|
@@ -2,6 +2,7 @@ const { prisma } = require('../../database/prisma');
|
|
|
2
2
|
const {
|
|
3
3
|
CredentialRepositoryInterface,
|
|
4
4
|
} = require('./credential-repository-interface');
|
|
5
|
+
const { tallyActiveCredentialsByType } = require('./credential-active-type');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* PostgreSQL Credential Repository Adapter
|
|
@@ -248,6 +249,30 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
|
|
|
248
249
|
};
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Count credentials active since a timestamp, grouped by integration type.
|
|
254
|
+
*
|
|
255
|
+
* @param {Object} params
|
|
256
|
+
* @param {Date} [params.since] - Lower bound on updatedAt
|
|
257
|
+
* @returns {Promise<Array<{ integrationType: string, count: number }>>}
|
|
258
|
+
*/
|
|
259
|
+
async countActiveByType({ since } = {}) {
|
|
260
|
+
const where = {};
|
|
261
|
+
if (since) where.updatedAt = { gte: since };
|
|
262
|
+
|
|
263
|
+
// Select only the entity moduleName (+ id) so the encrypted `data` JSON
|
|
264
|
+
// is never read and the encryption extension has nothing to decrypt.
|
|
265
|
+
const credentials = await this.prisma.credential.findMany({
|
|
266
|
+
where,
|
|
267
|
+
select: {
|
|
268
|
+
id: true,
|
|
269
|
+
entities: { select: { moduleName: true } },
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
return tallyActiveCredentialsByType(credentials);
|
|
274
|
+
}
|
|
275
|
+
|
|
251
276
|
/**
|
|
252
277
|
* Convert identifiers to Prisma where clause (converting IDs to Int)
|
|
253
278
|
* @private
|
|
@@ -121,6 +121,60 @@ async function aggregate(client, collection, pipeline) {
|
|
|
121
121
|
return result?.cursor?.firstBatch || [];
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
// findMany/aggregate return ONLY the first batch (~101 docs); the drained variants below follow the cursor to completion so full scans don't silently truncate.
|
|
125
|
+
const DRAIN_BATCH_SIZE = 1000;
|
|
126
|
+
const MAX_DRAIN_BATCHES = 100000;
|
|
127
|
+
|
|
128
|
+
function isCursorOpen(id) {
|
|
129
|
+
if (id === undefined || id === null) return false;
|
|
130
|
+
if (typeof id === 'number') return id !== 0;
|
|
131
|
+
if (typeof id === 'bigint') return id !== 0n;
|
|
132
|
+
// Extended JSON can surface a 64-bit cursor id as { $numberLong: "..." }.
|
|
133
|
+
if (typeof id === 'object' && id.$numberLong !== undefined) {
|
|
134
|
+
return id.$numberLong !== '0';
|
|
135
|
+
}
|
|
136
|
+
return String(id) !== '0';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function drainCursor(client, collection, firstResult) {
|
|
140
|
+
const cursor = firstResult?.cursor || {};
|
|
141
|
+
const docs = [...(cursor.firstBatch || [])];
|
|
142
|
+
let cursorId = cursor.id;
|
|
143
|
+
let batches = 0;
|
|
144
|
+
|
|
145
|
+
while (isCursorOpen(cursorId) && batches < MAX_DRAIN_BATCHES) {
|
|
146
|
+
batches += 1;
|
|
147
|
+
const next = await client.$runCommandRaw({
|
|
148
|
+
getMore: cursorId,
|
|
149
|
+
collection,
|
|
150
|
+
batchSize: DRAIN_BATCH_SIZE,
|
|
151
|
+
});
|
|
152
|
+
const nextCursor = next?.cursor || {};
|
|
153
|
+
const nextBatch = nextCursor.nextBatch || [];
|
|
154
|
+
docs.push(...nextBatch);
|
|
155
|
+
cursorId = nextCursor.id;
|
|
156
|
+
if (nextBatch.length === 0) break;
|
|
157
|
+
}
|
|
158
|
+
return docs;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function findManyDrained(client, collection, filter = {}, options = {}) {
|
|
162
|
+
const command = { find: collection, filter, batchSize: DRAIN_BATCH_SIZE };
|
|
163
|
+
if (options.projection) command.projection = options.projection;
|
|
164
|
+
if (options.sort) command.sort = options.sort;
|
|
165
|
+
const first = await client.$runCommandRaw(command);
|
|
166
|
+
return drainCursor(client, collection, first);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function aggregateDrained(client, collection, pipeline) {
|
|
170
|
+
const first = await client.$runCommandRaw({
|
|
171
|
+
aggregate: collection,
|
|
172
|
+
pipeline,
|
|
173
|
+
cursor: { batchSize: DRAIN_BATCH_SIZE },
|
|
174
|
+
});
|
|
175
|
+
return drainCursor(client, collection, first);
|
|
176
|
+
}
|
|
177
|
+
|
|
124
178
|
module.exports = {
|
|
125
179
|
toObjectId,
|
|
126
180
|
toObjectIdArray,
|
|
@@ -132,5 +186,7 @@ module.exports = {
|
|
|
132
186
|
deleteOne,
|
|
133
187
|
deleteMany,
|
|
134
188
|
aggregate,
|
|
189
|
+
findManyDrained,
|
|
190
|
+
aggregateDrained,
|
|
135
191
|
};
|
|
136
192
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve Migration Via Worker Use Case
|
|
3
|
+
*
|
|
4
|
+
* Resolves a failed Prisma migration (P3009) by invoking the worker Lambda,
|
|
5
|
+
* which has the Prisma CLI installed. Keeps the router Lambda lightweight —
|
|
6
|
+
* same delegation pattern as GetDatabaseStateViaWorkerUseCase.
|
|
7
|
+
*/
|
|
8
|
+
class ResolveMigrationViaWorkerUseCase {
|
|
9
|
+
/**
|
|
10
|
+
* @param {Object} dependencies
|
|
11
|
+
* @param {LambdaInvoker} dependencies.lambdaInvoker - Lambda invocation adapter
|
|
12
|
+
* @param {string} dependencies.workerFunctionName - Worker Lambda function name
|
|
13
|
+
*/
|
|
14
|
+
constructor({ lambdaInvoker, workerFunctionName }) {
|
|
15
|
+
if (!lambdaInvoker) {
|
|
16
|
+
throw new Error('lambdaInvoker dependency is required');
|
|
17
|
+
}
|
|
18
|
+
if (!workerFunctionName) {
|
|
19
|
+
throw new Error('workerFunctionName is required');
|
|
20
|
+
}
|
|
21
|
+
this.lambdaInvoker = lambdaInvoker;
|
|
22
|
+
this.workerFunctionName = workerFunctionName;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {Object} params
|
|
27
|
+
* @param {string} params.migrationName - Migration to resolve
|
|
28
|
+
* @param {'applied'|'rolled-back'} [params.action] - Resolution mode
|
|
29
|
+
* @param {string} [params.stage] - Deployment stage
|
|
30
|
+
* @returns {Promise<Object>} Worker result body
|
|
31
|
+
*/
|
|
32
|
+
async execute({ migrationName, action = 'applied', stage }) {
|
|
33
|
+
const dbType = process.env.DB_TYPE || 'postgresql';
|
|
34
|
+
|
|
35
|
+
console.log(
|
|
36
|
+
`Invoking worker Lambda to resolve migration "${migrationName}" as ${action}: ${this.workerFunctionName}`
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return this.lambdaInvoker.invoke(this.workerFunctionName, {
|
|
40
|
+
action: 'resolve',
|
|
41
|
+
migrationName,
|
|
42
|
+
resolveAction: action,
|
|
43
|
+
dbType,
|
|
44
|
+
stage,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { ResolveMigrationViaWorkerUseCase };
|
|
@@ -405,14 +405,25 @@ async function runPrismaMigrateResolve(migrationName, action = 'applied', verbos
|
|
|
405
405
|
const [executable, ...executableArgs] = prismaBin.split(' ');
|
|
406
406
|
const fullArgs = [...executableArgs, ...args];
|
|
407
407
|
|
|
408
|
+
let stdout = '';
|
|
409
|
+
let stderr = '';
|
|
408
410
|
const proc = spawn(executable, fullArgs, {
|
|
409
|
-
stdio: 'inherit',
|
|
411
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
410
412
|
env: {
|
|
411
413
|
...process.env,
|
|
412
414
|
PRISMA_HIDE_UPDATE_MESSAGE: '1'
|
|
413
415
|
}
|
|
414
416
|
});
|
|
415
417
|
|
|
418
|
+
proc.stdout.on('data', (data) => {
|
|
419
|
+
stdout += data.toString();
|
|
420
|
+
if (verbose) process.stdout.write(data);
|
|
421
|
+
});
|
|
422
|
+
proc.stderr.on('data', (data) => {
|
|
423
|
+
stderr += data.toString();
|
|
424
|
+
if (verbose) process.stderr.write(data);
|
|
425
|
+
});
|
|
426
|
+
|
|
416
427
|
proc.on('error', (error) => {
|
|
417
428
|
resolve({
|
|
418
429
|
success: false,
|
|
@@ -427,9 +438,12 @@ async function runPrismaMigrateResolve(migrationName, action = 'applied', verbos
|
|
|
427
438
|
output: `Migration ${migrationName} marked as ${action}`
|
|
428
439
|
});
|
|
429
440
|
} else {
|
|
441
|
+
const detail = (stderr || stdout).trim();
|
|
430
442
|
resolve({
|
|
431
443
|
success: false,
|
|
432
|
-
error:
|
|
444
|
+
error: detail
|
|
445
|
+
? `Prisma migrate resolve failed (exit ${code}): ${detail}`
|
|
446
|
+
: `Resolve process exited with code ${code}`
|
|
433
447
|
});
|
|
434
448
|
}
|
|
435
449
|
});
|
|
@@ -8,7 +8,7 @@ const { resolveTelemetryConfig } = require('../telemetry/telemetry-config');
|
|
|
8
8
|
* @function loadAppDefinition
|
|
9
9
|
* @description Searches for the nearest backend package.json, loads the corresponding index.js file,
|
|
10
10
|
* and extracts the application definition containing integrations and user configuration.
|
|
11
|
-
* @returns {{integrations: Array<object>, userConfig: object | null, adminScripts: Array<object>, admin: object, telemetry: object}} An object containing the application definition.
|
|
11
|
+
* @returns {{integrations: Array<object>, userConfig: object | null, adminScripts: Array<object>, reports: Array<object>, admin: object, telemetry: object}} An object containing the application definition.
|
|
12
12
|
* @throws {Error} Throws error if backend package.json cannot be found.
|
|
13
13
|
* @throws {Error} Throws error if index.js file cannot be found in the backend directory.
|
|
14
14
|
* @example
|
|
@@ -34,6 +34,7 @@ function loadAppDefinition() {
|
|
|
34
34
|
integrations = [],
|
|
35
35
|
user: userConfig = null,
|
|
36
36
|
adminScripts = [],
|
|
37
|
+
reports = [],
|
|
37
38
|
admin = {},
|
|
38
39
|
} = appDefinition;
|
|
39
40
|
|
|
@@ -58,7 +59,7 @@ function loadAppDefinition() {
|
|
|
58
59
|
};
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
return { integrations, userConfig, adminScripts, admin, telemetry };
|
|
62
|
+
return { integrations, userConfig, adminScripts, reports, admin, telemetry };
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
module.exports = {
|
|
@@ -31,10 +31,16 @@ const {
|
|
|
31
31
|
ValidationError: GetValidationError,
|
|
32
32
|
NotFoundError,
|
|
33
33
|
} = require('../../database/use-cases/get-migration-status-use-case');
|
|
34
|
-
const {
|
|
34
|
+
const {
|
|
35
|
+
LambdaInvoker,
|
|
36
|
+
LambdaInvocationError,
|
|
37
|
+
} = require('../../database/adapters/lambda-invoker');
|
|
35
38
|
const {
|
|
36
39
|
GetDatabaseStateViaWorkerUseCase,
|
|
37
40
|
} = require('../../database/use-cases/get-database-state-via-worker-use-case');
|
|
41
|
+
const {
|
|
42
|
+
ResolveMigrationViaWorkerUseCase,
|
|
43
|
+
} = require('../../database/use-cases/resolve-migration-via-worker-use-case');
|
|
38
44
|
|
|
39
45
|
const router = Router();
|
|
40
46
|
|
|
@@ -58,6 +64,10 @@ const getDatabaseStateUseCase = new GetDatabaseStateViaWorkerUseCase({
|
|
|
58
64
|
lambdaInvoker,
|
|
59
65
|
workerFunctionName,
|
|
60
66
|
});
|
|
67
|
+
const resolveMigrationUseCase = new ResolveMigrationViaWorkerUseCase({
|
|
68
|
+
lambdaInvoker,
|
|
69
|
+
workerFunctionName,
|
|
70
|
+
});
|
|
61
71
|
|
|
62
72
|
// Apply admin API key validation to all routes (shared middleware)
|
|
63
73
|
router.use(validateAdminApiKey);
|
|
@@ -255,6 +265,13 @@ router.post(
|
|
|
255
265
|
});
|
|
256
266
|
}
|
|
257
267
|
|
|
268
|
+
if (!/^\d{14}_[a-z0-9_]+$/i.test(migrationName)) {
|
|
269
|
+
return res.status(400).json({
|
|
270
|
+
success: false,
|
|
271
|
+
error: 'migrationName is not a valid migration identifier'
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
258
275
|
if (!['applied', 'rolled-back'].includes(action)) {
|
|
259
276
|
return res.status(400).json({
|
|
260
277
|
success: false,
|
|
@@ -262,30 +279,31 @@ router.post(
|
|
|
262
279
|
});
|
|
263
280
|
}
|
|
264
281
|
|
|
265
|
-
|
|
266
|
-
// Import prismaRunner here to avoid circular dependencies
|
|
267
|
-
const prismaRunner = require('../../database/utils/prisma-runner');
|
|
268
|
-
|
|
269
|
-
const result = await prismaRunner.runPrismaMigrateResolve(migrationName, action, true);
|
|
282
|
+
const stage = req.body.stage || process.env.STAGE || 'production';
|
|
270
283
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
success: false,
|
|
274
|
-
error: `Failed to resolve migration: ${result.error}`
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
res.status(200).json({
|
|
279
|
-
success: true,
|
|
280
|
-
message: `Migration ${migrationName} marked as ${action}`,
|
|
284
|
+
try {
|
|
285
|
+
const result = await resolveMigrationUseCase.execute({
|
|
281
286
|
migrationName,
|
|
282
|
-
action
|
|
287
|
+
action,
|
|
288
|
+
stage,
|
|
283
289
|
});
|
|
290
|
+
|
|
291
|
+
res.status(200).json(result);
|
|
284
292
|
} catch (error) {
|
|
285
293
|
console.error('Migration resolve failed:', error);
|
|
294
|
+
if (
|
|
295
|
+
error instanceof LambdaInvocationError &&
|
|
296
|
+
error.statusCode === 400
|
|
297
|
+
) {
|
|
298
|
+
return res.status(400).json({
|
|
299
|
+
success: false,
|
|
300
|
+
error: error.message,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
286
303
|
return res.status(500).json({
|
|
287
304
|
success: false,
|
|
288
|
-
error:
|
|
305
|
+
error: 'Failed to resolve migration',
|
|
306
|
+
details: error.message,
|
|
289
307
|
});
|
|
290
308
|
}
|
|
291
309
|
})
|
|
@@ -188,6 +188,81 @@ exports.handler = async (event, context) => {
|
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
if (action === 'resolve') {
|
|
192
|
+
const { migrationName, resolveAction = 'applied' } = event;
|
|
193
|
+
console.log(`\n========================================`);
|
|
194
|
+
console.log(
|
|
195
|
+
`Action: resolve (migration=${migrationName}, mode=${resolveAction})`
|
|
196
|
+
);
|
|
197
|
+
console.log(`========================================`);
|
|
198
|
+
|
|
199
|
+
if (!migrationName) {
|
|
200
|
+
return {
|
|
201
|
+
statusCode: 400,
|
|
202
|
+
body: { success: false, error: 'migrationName is required' },
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (!/^\d{14}_[a-z0-9_]+$/i.test(migrationName)) {
|
|
206
|
+
return {
|
|
207
|
+
statusCode: 400,
|
|
208
|
+
body: {
|
|
209
|
+
success: false,
|
|
210
|
+
error: 'migrationName is not a valid migration identifier',
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (!['applied', 'rolled-back'].includes(resolveAction)) {
|
|
215
|
+
return {
|
|
216
|
+
statusCode: 400,
|
|
217
|
+
body: {
|
|
218
|
+
success: false,
|
|
219
|
+
error: 'resolveAction must be "applied" or "rolled-back"',
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (dbType !== 'postgresql') {
|
|
224
|
+
return {
|
|
225
|
+
statusCode: 400,
|
|
226
|
+
body: {
|
|
227
|
+
success: false,
|
|
228
|
+
error: `Migration resolve is only supported for postgresql, not "${dbType}"`,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const result = await prismaRunner.runPrismaMigrateResolve(
|
|
235
|
+
migrationName,
|
|
236
|
+
resolveAction,
|
|
237
|
+
true
|
|
238
|
+
);
|
|
239
|
+
if (!result.success) {
|
|
240
|
+
return {
|
|
241
|
+
statusCode: 500,
|
|
242
|
+
body: {
|
|
243
|
+
success: false,
|
|
244
|
+
error: sanitizeError(result.error),
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
statusCode: 200,
|
|
250
|
+
body: {
|
|
251
|
+
success: true,
|
|
252
|
+
message: `Migration ${migrationName} marked as ${resolveAction}`,
|
|
253
|
+
migrationName,
|
|
254
|
+
action: resolveAction,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
} catch (error) {
|
|
258
|
+
console.error('❌ Migration resolve failed:', error.message);
|
|
259
|
+
return {
|
|
260
|
+
statusCode: 500,
|
|
261
|
+
body: { success: false, error: sanitizeError(error.message) },
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
191
266
|
// Otherwise, handle migration (existing code)
|
|
192
267
|
console.log(`\n========================================`);
|
|
193
268
|
console.log(`Action: migrate (migrationId=${migrationId || 'new'})`);
|
package/index.js
CHANGED
|
@@ -66,8 +66,10 @@ const {
|
|
|
66
66
|
LoadIntegrationContextUseCase,
|
|
67
67
|
} = require('./integrations/index');
|
|
68
68
|
const {
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
ReportBase,
|
|
70
|
+
IntegrationsReport,
|
|
71
|
+
BUILTIN_REPORTS,
|
|
72
|
+
createReportCommands,
|
|
71
73
|
} = require('./reporting/index');
|
|
72
74
|
const {
|
|
73
75
|
createTelemetry,
|
|
@@ -144,8 +146,10 @@ module.exports = {
|
|
|
144
146
|
GetProcess,
|
|
145
147
|
|
|
146
148
|
// reporting
|
|
147
|
-
|
|
148
|
-
|
|
149
|
+
ReportBase,
|
|
150
|
+
IntegrationsReport,
|
|
151
|
+
BUILTIN_REPORTS,
|
|
152
|
+
createReportCommands,
|
|
149
153
|
|
|
150
154
|
// telemetry
|
|
151
155
|
createTelemetry,
|
|
@@ -8,6 +8,7 @@ const {
|
|
|
8
8
|
updateOne,
|
|
9
9
|
deleteOne,
|
|
10
10
|
deleteMany,
|
|
11
|
+
aggregateDrained,
|
|
11
12
|
} = require('../../database/documentdb-utils');
|
|
12
13
|
const {
|
|
13
14
|
IntegrationMappingRepositoryInterface,
|
|
@@ -15,6 +16,7 @@ const {
|
|
|
15
16
|
const {
|
|
16
17
|
DocumentDBEncryptionService,
|
|
17
18
|
} = require('../../database/documentdb-encryption-service');
|
|
19
|
+
|
|
18
20
|
class IntegrationMappingRepositoryDocumentDB extends IntegrationMappingRepositoryInterface {
|
|
19
21
|
constructor() {
|
|
20
22
|
super();
|
|
@@ -22,6 +24,27 @@ class IntegrationMappingRepositoryDocumentDB extends IntegrationMappingRepositor
|
|
|
22
24
|
this.encryptionService = new DocumentDBEncryptionService();
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* integrationId is stored as a string in DocumentDB, so ids are matched as
|
|
29
|
+
* strings (an ObjectId $in would never match). Drains the grouped cursor so
|
|
30
|
+
* a deployment-wide count is not truncated at the first batch.
|
|
31
|
+
*/
|
|
32
|
+
async countByIntegrationIds(ids = []) {
|
|
33
|
+
const counts = new Map();
|
|
34
|
+
if (!ids || ids.length === 0) return counts;
|
|
35
|
+
|
|
36
|
+
const stringIds = ids.map(String);
|
|
37
|
+
const rows = await aggregateDrained(this.prisma, 'IntegrationMapping', [
|
|
38
|
+
{ $match: { integrationId: { $in: stringIds } } },
|
|
39
|
+
{ $group: { _id: '$integrationId', count: { $sum: 1 } } },
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
for (const row of rows) {
|
|
43
|
+
counts.set(String(row?._id), row?.count ?? 0);
|
|
44
|
+
}
|
|
45
|
+
return counts;
|
|
46
|
+
}
|
|
47
|
+
|
|
25
48
|
async findMappingBy(integrationId, sourceId) {
|
|
26
49
|
const filter = this._compositeFilter(integrationId, sourceId);
|
|
27
50
|
const doc = await findOne(this.prisma, 'IntegrationMapping', filter);
|
|
@@ -77,6 +77,20 @@ class IntegrationMappingRepositoryInterface {
|
|
|
77
77
|
);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Count mappings grouped by integration id, for a bounded set of ids.
|
|
82
|
+
* Adapters must drain the full grouped result (a deployment can have more
|
|
83
|
+
* than one first-batch of distinct integration ids).
|
|
84
|
+
*
|
|
85
|
+
* @returns {Promise<Map<string, number>>} Map of integrationId (string) → count
|
|
86
|
+
* @abstract
|
|
87
|
+
*/
|
|
88
|
+
async countByIntegrationIds(ids) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
'Method countByIntegrationIds must be implemented by subclass'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
80
94
|
/**
|
|
81
95
|
* Find mapping by ID
|
|
82
96
|
*
|
|
@@ -133,6 +133,28 @@ class IntegrationMappingRepositoryMongo extends IntegrationMappingRepositoryInte
|
|
|
133
133
|
};
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Count mappings grouped by integration id for a bounded id set.
|
|
138
|
+
*
|
|
139
|
+
* @param {Array<string>} ids - Integration ids
|
|
140
|
+
* @returns {Promise<Map<string, number>>} integrationId (string) → count
|
|
141
|
+
*/
|
|
142
|
+
async countByIntegrationIds(ids = []) {
|
|
143
|
+
const counts = new Map();
|
|
144
|
+
if (!ids || ids.length === 0) return counts;
|
|
145
|
+
|
|
146
|
+
const groups = await this.prisma.integrationMapping.groupBy({
|
|
147
|
+
by: ['integrationId'],
|
|
148
|
+
where: { integrationId: { in: ids } },
|
|
149
|
+
_count: { _all: true },
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
for (const group of groups) {
|
|
153
|
+
counts.set(String(group.integrationId), group._count._all);
|
|
154
|
+
}
|
|
155
|
+
return counts;
|
|
156
|
+
}
|
|
157
|
+
|
|
136
158
|
/**
|
|
137
159
|
* Find mapping by ID
|
|
138
160
|
* @param {string} id - Mapping ID
|
|
@@ -2,6 +2,7 @@ const { prisma } = require('../../database/prisma');
|
|
|
2
2
|
const {
|
|
3
3
|
IntegrationMappingRepositoryInterface,
|
|
4
4
|
} = require('./integration-mapping-repository-interface');
|
|
5
|
+
const { strictIntId } = require('./report-id');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* PostgreSQL Integration Mapping Repository Adapter
|
|
@@ -188,6 +189,33 @@ class IntegrationMappingRepositoryPostgres extends IntegrationMappingRepositoryI
|
|
|
188
189
|
};
|
|
189
190
|
}
|
|
190
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Count mappings grouped by integration id for a bounded id set.
|
|
194
|
+
*
|
|
195
|
+
* @param {Array<string|number>} ids - Integration ids
|
|
196
|
+
* @returns {Promise<Map<string, number>>} integrationId (string) → count
|
|
197
|
+
*/
|
|
198
|
+
async countByIntegrationIds(ids = []) {
|
|
199
|
+
const counts = new Map();
|
|
200
|
+
if (!ids || ids.length === 0) return counts;
|
|
201
|
+
|
|
202
|
+
// Strict (matches findAllForReport): reject partially-numeric ids instead of coercing.
|
|
203
|
+
const intIds = ids.map((id) => strictIntId(id));
|
|
204
|
+
const groups = await this.prisma.integrationMapping.groupBy({
|
|
205
|
+
by: ['integrationId'],
|
|
206
|
+
where: { integrationId: { in: intIds } },
|
|
207
|
+
_count: { _all: true },
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
for (const group of groups) {
|
|
211
|
+
counts.set(
|
|
212
|
+
this._intToString(group.integrationId),
|
|
213
|
+
group._count._all
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return counts;
|
|
217
|
+
}
|
|
218
|
+
|
|
191
219
|
/**
|
|
192
220
|
* Find mapping by ID
|
|
193
221
|
* @param {string} id - Mapping ID (string from application layer)
|
|
@@ -4,6 +4,7 @@ const {
|
|
|
4
4
|
toObjectIdArray,
|
|
5
5
|
fromObjectId,
|
|
6
6
|
findMany,
|
|
7
|
+
findManyDrained,
|
|
7
8
|
findOne,
|
|
8
9
|
insertOne,
|
|
9
10
|
updateOne,
|
|
@@ -254,6 +255,43 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
|
|
|
254
255
|
return this._mapIntegration(updated);
|
|
255
256
|
}
|
|
256
257
|
|
|
258
|
+
// Drain the full cursor so a deployment-wide report is never truncated.
|
|
259
|
+
async findAllForReport({ status, userId } = {}) {
|
|
260
|
+
const filter = {};
|
|
261
|
+
if (status) filter.status = status;
|
|
262
|
+
if (userId !== undefined && userId !== null) {
|
|
263
|
+
const objectId = toObjectId(userId);
|
|
264
|
+
// Invalid userId means no matches — don't fall through to an unfiltered whole-deployment query.
|
|
265
|
+
if (!objectId) return [];
|
|
266
|
+
filter.userId = objectId;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const docs = await findManyDrained(this.prisma, 'Integration', filter);
|
|
270
|
+
|
|
271
|
+
return docs.map((doc) => {
|
|
272
|
+
const errors = this._extractReportErrors(doc);
|
|
273
|
+
return {
|
|
274
|
+
id: fromObjectId(doc?._id),
|
|
275
|
+
type: doc?.config?.type ?? null,
|
|
276
|
+
status: doc?.status ?? null,
|
|
277
|
+
userId: fromObjectId(doc?.userId) ?? null,
|
|
278
|
+
version: doc?.version ?? null,
|
|
279
|
+
errorCount: Array.isArray(errors) ? errors.length : 0,
|
|
280
|
+
moduleCount: Array.isArray(doc?.entityIds)
|
|
281
|
+
? doc.entityIds.length
|
|
282
|
+
: 0,
|
|
283
|
+
createdAt: doc?.createdAt ?? null,
|
|
284
|
+
updatedAt: doc?.updatedAt ?? null,
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
_extractReportErrors(doc) {
|
|
290
|
+
if (Array.isArray(doc?.errors)) return doc.errors;
|
|
291
|
+
if (Array.isArray(doc?.messages?.errors)) return doc.messages.errors;
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
|
|
257
295
|
_mapIntegration(doc) {
|
|
258
296
|
const messages = this._extractMessages(doc);
|
|
259
297
|
return {
|
|
@@ -35,6 +35,22 @@ class IntegrationRepositoryInterface {
|
|
|
35
35
|
throw new Error('Method findIntegrations must be implemented by subclass');
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Find every integration in a report-shaped projection, optionally
|
|
40
|
+
* filtered by status and/or owning user. Adapters must drain the full
|
|
41
|
+
* result set (no first-batch truncation) since this powers a
|
|
42
|
+
* deployment-wide scan.
|
|
43
|
+
*
|
|
44
|
+
* @param {Object} [filter={}]
|
|
45
|
+
* @param {string} [filter.status] - Integration status
|
|
46
|
+
* @param {string|number} [filter.userId] - Owning user ID
|
|
47
|
+
* @returns {Promise<Array<{id, type, status, userId, version, errorCount, moduleCount, createdAt, updatedAt}>>}
|
|
48
|
+
* @abstract
|
|
49
|
+
*/
|
|
50
|
+
async findAllForReport(filter = {}) {
|
|
51
|
+
throw new Error('Method findAllForReport must be implemented by subclass');
|
|
52
|
+
}
|
|
53
|
+
|
|
38
54
|
/**
|
|
39
55
|
* Delete integration by ID
|
|
40
56
|
*
|