@friggframework/core 2.0.0-next.104 → 2.0.0-next.106

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 (45) hide show
  1. package/admin-scripts/repositories/admin-script-execution-repository-interface.js +7 -2
  2. package/admin-scripts/repositories/admin-script-execution-repository-mongo.js +16 -6
  3. package/admin-scripts/repositories/admin-script-execution-repository-postgres.js +16 -6
  4. package/application/commands/admin-script-commands.js +2 -1
  5. package/application/commands/credential-commands.js +17 -0
  6. package/application/commands/integration-commands.js +14 -1
  7. package/application/commands/integration-mapping-commands.js +25 -0
  8. package/application/commands/report-commands.js +188 -0
  9. package/artifacts/repositories/artifact-repository-factory.js +19 -0
  10. package/artifacts/repositories/artifact-repository-interface.js +27 -0
  11. package/artifacts/repositories/artifact-repository-local.js +42 -0
  12. package/artifacts/repositories/artifact-repository-s3.js +61 -0
  13. package/credential/repositories/credential-active-type.js +32 -0
  14. package/credential/repositories/credential-repository-documentdb.js +51 -0
  15. package/credential/repositories/credential-repository-interface.js +15 -0
  16. package/credential/repositories/credential-repository-mongo.js +25 -0
  17. package/credential/repositories/credential-repository-postgres.js +25 -0
  18. package/database/documentdb-utils.js +56 -0
  19. package/handlers/app-definition-loader.js +3 -2
  20. package/index.js +8 -4
  21. package/integrations/integration-base.js +43 -5
  22. package/integrations/repositories/integration-mapping-repository-documentdb.js +23 -0
  23. package/integrations/repositories/integration-mapping-repository-interface.js +14 -0
  24. package/integrations/repositories/integration-mapping-repository-mongo.js +22 -0
  25. package/integrations/repositories/integration-mapping-repository-postgres.js +28 -0
  26. package/integrations/repositories/integration-repository-documentdb.js +38 -0
  27. package/integrations/repositories/integration-repository-interface.js +16 -0
  28. package/integrations/repositories/integration-repository-mongo.js +36 -0
  29. package/integrations/repositories/integration-repository-postgres.js +40 -0
  30. package/integrations/repositories/report-id.js +13 -0
  31. package/modules/requester/oauth-2.js +19 -4
  32. package/package.json +7 -5
  33. package/reporting/README.md +109 -48
  34. package/reporting/builtin-reports.js +6 -0
  35. package/reporting/index.js +9 -13
  36. package/reporting/report-base.js +49 -0
  37. package/reporting/{use-cases/list-integrations-report.js → reports/integrations-report.js} +69 -37
  38. package/handlers/routers/reporting.js +0 -9
  39. package/reporting/reporting-router.js +0 -84
  40. package/reporting/repositories/reporting-repository-documentdb.js +0 -127
  41. package/reporting/repositories/reporting-repository-factory.js +0 -35
  42. package/reporting/repositories/reporting-repository-interface.js +0 -16
  43. package/reporting/repositories/reporting-repository-mongo.js +0 -54
  44. package/reporting/repositories/reporting-repository-postgres.js +0 -70
  45. package/reporting/use-cases/index.js +0 -6
@@ -34,7 +34,7 @@ class AdminScriptExecutionRepositoryInterface {
34
34
  * @param {string} [params.context.audit.apiKeyName] - Name of API key used
35
35
  * @param {string} [params.context.audit.apiKeyLast4] - Last 4 chars of API key
36
36
  * @param {string} [params.context.audit.ipAddress] - IP address of requester
37
- * @param {string|number} [params.parentExecutionId] - ID of the execution that queued this one; persisted to the parentExecutionId column (self-FK) for the parent/child hierarchy
37
+ * @param {string|number} [params.parentExecutionId] - ID of the execution that queued this one, for the parent/child hierarchy
38
38
  * @returns {Promise<Object>} The created process record
39
39
  * @abstract
40
40
  */
@@ -65,6 +65,9 @@ class AdminScriptExecutionRepositoryInterface {
65
65
  * @param {string} [options.sortBy] - Field to sort by
66
66
  * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc')
67
67
  * @param {string} [options.state] - Optional state filter ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED')
68
+ * @param {string} [options.type] - Optional type filter ('ADMIN_SCRIPT', 'REPORT', 'DB_MIGRATION')
69
+ * @param {Date} [options.from] - Optional lower bound (inclusive) on createdAt
70
+ * @param {Date} [options.to] - Optional upper bound (inclusive) on createdAt
68
71
  * @returns {Promise<Array>} Array of process records
69
72
  * @abstract
70
73
  */
@@ -153,10 +156,12 @@ class AdminScriptExecutionRepositoryInterface {
153
156
  * Used for cleanup and retention policies
154
157
  *
155
158
  * @param {Date} date - Delete processes older than this date
159
+ * @param {Object} [options] - Deletion options
160
+ * @param {string} [options.type] - Optional type filter, so report vs script retention can diverge
156
161
  * @returns {Promise<Object>} Deletion result with count
157
162
  * @abstract
158
163
  */
159
- async deleteExecutionsOlderThan(date) {
164
+ async deleteExecutionsOlderThan(date, options = {}) {
160
165
  throw new Error(
161
166
  'Method deleteExecutionsOlderThan must be implemented by subclass'
162
167
  );
@@ -75,10 +75,19 @@ class AdminScriptExecutionRepositoryMongo extends AdminScriptExecutionRepository
75
75
  sortBy = 'createdAt',
76
76
  sortOrder = 'desc',
77
77
  state,
78
+ type,
79
+ from,
80
+ to,
78
81
  } = options;
79
82
 
80
83
  const where = { name };
81
84
  if (state) where.state = state;
85
+ if (type) where.type = type;
86
+ if (from || to) {
87
+ where.createdAt = {};
88
+ if (from) where.createdAt.gte = from;
89
+ if (to) where.createdAt.lte = to;
90
+ }
82
91
 
83
92
  const processes = await this.prisma.adminScriptExecution.findMany({
84
93
  where,
@@ -207,15 +216,16 @@ class AdminScriptExecutionRepositoryMongo extends AdminScriptExecutionRepository
207
216
  * Used for cleanup and retention policies
208
217
  *
209
218
  * @param {Date} date - Delete processes older than this date
219
+ * @param {Object} [options] - Deletion options
220
+ * @param {string} [options.type] - Optional type filter
210
221
  * @returns {Promise<Object>} Deletion result with count
211
222
  */
212
- async deleteExecutionsOlderThan(date) {
223
+ async deleteExecutionsOlderThan(date, { type } = {}) {
224
+ const where = { createdAt: { lt: date } };
225
+ if (type) where.type = type;
226
+
213
227
  const result = await this.prisma.adminScriptExecution.deleteMany({
214
- where: {
215
- createdAt: {
216
- lt: date,
217
- },
218
- },
228
+ where,
219
229
  });
220
230
 
221
231
  return {
@@ -110,10 +110,19 @@ class AdminScriptExecutionRepositoryPostgres extends AdminScriptExecutionReposit
110
110
  sortBy = 'createdAt',
111
111
  sortOrder = 'desc',
112
112
  state,
113
+ type,
114
+ from,
115
+ to,
113
116
  } = options;
114
117
 
115
118
  const where = { name };
116
119
  if (state) where.state = state;
120
+ if (type) where.type = type;
121
+ if (from || to) {
122
+ where.createdAt = {};
123
+ if (from) where.createdAt.gte = from;
124
+ if (to) where.createdAt.lte = to;
125
+ }
117
126
 
118
127
  const processes = await this.prisma.adminScriptExecution.findMany({
119
128
  where,
@@ -247,15 +256,16 @@ class AdminScriptExecutionRepositoryPostgres extends AdminScriptExecutionReposit
247
256
  * Used for cleanup and retention policies
248
257
  *
249
258
  * @param {Date} date - Delete processes older than this date
259
+ * @param {Object} [options] - Deletion options
260
+ * @param {string} [options.type] - Optional type filter
250
261
  * @returns {Promise<Object>} Deletion result with count
251
262
  */
252
- async deleteExecutionsOlderThan(date) {
263
+ async deleteExecutionsOlderThan(date, { type } = {}) {
264
+ const where = { createdAt: { lt: date } };
265
+ if (type) where.type = type;
266
+
253
267
  const result = await this.prisma.adminScriptExecution.deleteMany({
254
- where: {
255
- createdAt: {
256
- lt: date,
257
- },
258
- },
268
+ where,
259
269
  });
260
270
 
261
271
  return {
@@ -105,7 +105,8 @@ function createAdminScriptCommands() {
105
105
  const process = await adminScriptExecutionRepository.findExecutionById(
106
106
  processId
107
107
  );
108
- if (!process) {
108
+ // Scripts and reports share one store; exclude REPORT rows so the two never read each other's executions.
109
+ if (!process || process.type === 'REPORT') {
109
110
  const error = new Error(`Execution ${processId} not found`);
110
111
  error.code = 'EXECUTION_NOT_FOUND';
111
112
  return mapErrorToResponse(error);
@@ -216,6 +216,23 @@ function createCredentialCommands() {
216
216
  }
217
217
  },
218
218
 
219
+ /**
220
+ * Count credentials active (updatedAt >= since) grouped by integration
221
+ * type, derived from the related Entity.moduleName. Returns a non-secret
222
+ * projection only — never reads or decrypts credential secrets.
223
+ *
224
+ * @param {Object} params
225
+ * @param {Date} [params.since] - Lower bound on updatedAt
226
+ * @returns {Promise<Array<{ integrationType: string, count: number }>>}
227
+ */
228
+ async countActiveByType({ since } = {}) {
229
+ try {
230
+ return await credRepo.countActiveByType({ since });
231
+ } catch (error) {
232
+ return mapErrorToResponse(error);
233
+ }
234
+ },
235
+
219
236
  /**
220
237
  * Delete a credential by ID (alias for deleteCredential)
221
238
  * @param {string} credentialId - Credential ID to delete
@@ -89,9 +89,21 @@ function createIntegrationCommands({ integrationClass } = {}) {
89
89
  }
90
90
  }
91
91
 
92
+ /**
93
+ * Report-shaped projection (derived counters + timestamps) — the
94
+ * cross-integration read that reports (ADR-010) consume via commands.
95
+ */
96
+ async function listForReport(filter = {}) {
97
+ try {
98
+ return await integrationRepository.findAllForReport(filter);
99
+ } catch (error) {
100
+ return mapErrorToResponse(error);
101
+ }
102
+ }
103
+
92
104
  // The remaining commands hydrate/modify integrations for a specific class.
93
105
  if (!integrationClass) {
94
- return { findIntegrationById, listIntegrations };
106
+ return { findIntegrationById, listIntegrations, listForReport };
95
107
  }
96
108
 
97
109
  const moduleRepository = createModuleRepository();
@@ -154,6 +166,7 @@ function createIntegrationCommands({ integrationClass } = {}) {
154
166
  return {
155
167
  findIntegrationById,
156
168
  listIntegrations,
169
+ listForReport,
157
170
 
158
171
  /**
159
172
  * Find integration context by external entity ID and type
@@ -0,0 +1,25 @@
1
+ const {
2
+ createIntegrationMappingRepository,
3
+ } = require('../../integrations/repositories/integration-mapping-repository-factory');
4
+
5
+ function mapErrorToResponse(error) {
6
+ return { error: 500, reason: error?.message, code: error?.code };
7
+ }
8
+
9
+ // Kept separate from integration-commands so the mapping and integration domains stay decoupled.
10
+ function createIntegrationMappingCommands() {
11
+ const mappingRepository = createIntegrationMappingRepository();
12
+
13
+ return {
14
+ // Returns a Map of integrationId → count, or an error object on failure.
15
+ async countByIntegrationIds(ids = []) {
16
+ try {
17
+ return await mappingRepository.countByIntegrationIds(ids);
18
+ } catch (error) {
19
+ return mapErrorToResponse(error);
20
+ }
21
+ },
22
+ };
23
+ }
24
+
25
+ module.exports = { createIntegrationMappingCommands };
@@ -0,0 +1,188 @@
1
+ const ERROR_CODE_MAP = {
2
+ EXECUTION_NOT_FOUND: 404,
3
+ };
4
+
5
+ function mapErrorToResponse(error) {
6
+ const status = ERROR_CODE_MAP[error?.code] || 500;
7
+ return { error: status, reason: error?.message, code: error?.code };
8
+ }
9
+
10
+ /**
11
+ * Report commands share the AdminScriptExecution store, discriminated by
12
+ * type:'REPORT'. That store has no user/integration FK, and findExecutionById
13
+ * rejects non-REPORT rows, so a report lookup can never return another
14
+ * operation type's record (ADR-010 Decision 3).
15
+ */
16
+ function createReportCommands({ artifactRepository } = {}) {
17
+ const {
18
+ createAdminScriptExecutionRepository,
19
+ } = require('../../admin-scripts/repositories/admin-script-execution-repository-factory');
20
+
21
+ const executionRepository = createAdminScriptExecutionRepository();
22
+
23
+ let artifactRepo = artifactRepository || null;
24
+ function getArtifactRepository() {
25
+ if (!artifactRepo) {
26
+ const {
27
+ createArtifactRepository,
28
+ } = require('../../artifacts/repositories/artifact-repository-factory');
29
+ artifactRepo = createArtifactRepository();
30
+ }
31
+ return artifactRepo;
32
+ }
33
+
34
+ // Resolve to null rather than throw: a read must not fail because signing did.
35
+ async function signArtifact(ref) {
36
+ if (!ref) return null;
37
+ try {
38
+ return await getArtifactRepository().signedUrl(ref);
39
+ } catch (_error) {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ return {
45
+ async createExecution({
46
+ reportName,
47
+ reportVersion,
48
+ trigger,
49
+ mode,
50
+ input,
51
+ audit,
52
+ seriesName,
53
+ parentExecutionId,
54
+ }) {
55
+ try {
56
+ return await executionRepository.createExecution({
57
+ name: reportName,
58
+ type: 'REPORT',
59
+ parentExecutionId,
60
+ context: {
61
+ reportVersion,
62
+ trigger,
63
+ mode: mode || 'recorded',
64
+ input,
65
+ audit,
66
+ ...(seriesName ? { seriesName } : {}),
67
+ },
68
+ });
69
+ } catch (error) {
70
+ return mapErrorToResponse(error);
71
+ }
72
+ },
73
+
74
+ async findExecutionById(id) {
75
+ try {
76
+ const record = await executionRepository.findExecutionById(id);
77
+ if (!record || record.type !== 'REPORT') {
78
+ const error = new Error(`Report execution ${id} not found`);
79
+ error.code = 'EXECUTION_NOT_FOUND';
80
+ return mapErrorToResponse(error);
81
+ }
82
+ // The stored artifact ref is not retrievable on its own; sign it on read.
83
+ if (record.results?.artifact) {
84
+ record.results = {
85
+ ...record.results,
86
+ artifactUrl: await signArtifact(record.results.artifact),
87
+ };
88
+ }
89
+ return record;
90
+ } catch (error) {
91
+ return mapErrorToResponse(error);
92
+ }
93
+ },
94
+
95
+ // Never-throws: returns [] on error (non-critical read).
96
+ async listExecutionsByName(reportName, { limit, offset, state } = {}) {
97
+ try {
98
+ return await executionRepository.findExecutionsByName(
99
+ reportName,
100
+ { type: 'REPORT', limit, offset, state }
101
+ );
102
+ } catch (error) {
103
+ return [];
104
+ }
105
+ },
106
+
107
+ // No mode index in the store, so fetch by name/window and filter snapshots
108
+ // in JS. Never-throws: returns [] on error.
109
+ async findSnapshotSeries(reportName, { from, to, limit } = {}) {
110
+ try {
111
+ const rows = await executionRepository.findExecutionsByName(
112
+ reportName,
113
+ {
114
+ type: 'REPORT',
115
+ from,
116
+ to,
117
+ sortBy: 'createdAt',
118
+ sortOrder: 'asc',
119
+ limit,
120
+ }
121
+ );
122
+ const snapshots = rows.filter(
123
+ (row) => row.context?.mode === 'snapshot'
124
+ );
125
+ return Promise.all(
126
+ snapshots.map(async (row) => ({
127
+ executionId: row.id,
128
+ capturedAt: row.createdAt,
129
+ summary:
130
+ row.results?.output?.summary ??
131
+ row.results?.summary ??
132
+ null,
133
+ artifactUrl: await signArtifact(row.results?.artifact),
134
+ }))
135
+ );
136
+ } catch (error) {
137
+ return [];
138
+ }
139
+ },
140
+
141
+ async updateExecutionState(id, state) {
142
+ try {
143
+ return await executionRepository.updateExecutionState(id, state);
144
+ } catch (error) {
145
+ return mapErrorToResponse(error);
146
+ }
147
+ },
148
+
149
+ async appendExecutionLog(id, logEntry) {
150
+ try {
151
+ return await executionRepository.appendExecutionLog(id, logEntry);
152
+ } catch (error) {
153
+ return mapErrorToResponse(error);
154
+ }
155
+ },
156
+
157
+ // results.output is the inline JSON payload; results.summary + results.artifact
158
+ // are a large/binary payload's summary + object-store reference.
159
+ async completeExecution(
160
+ id,
161
+ { state, output, summary, artifact, error, metrics, logs } = {}
162
+ ) {
163
+ try {
164
+ if (state) {
165
+ await executionRepository.updateExecutionState(id, state);
166
+ }
167
+ const resultsUpdate = {};
168
+ if (output !== undefined) resultsUpdate.output = output;
169
+ if (summary !== undefined) resultsUpdate.summary = summary;
170
+ if (artifact !== undefined) resultsUpdate.artifact = artifact;
171
+ if (error) resultsUpdate.error = error;
172
+ if (metrics) resultsUpdate.metrics = metrics;
173
+ if (logs) resultsUpdate.logs = logs;
174
+ if (Object.keys(resultsUpdate).length > 0) {
175
+ await executionRepository.updateExecutionResults(
176
+ id,
177
+ resultsUpdate
178
+ );
179
+ }
180
+ return { success: true };
181
+ } catch (err) {
182
+ return mapErrorToResponse(err);
183
+ }
184
+ },
185
+ };
186
+ }
187
+
188
+ module.exports = { createReportCommands };
@@ -0,0 +1,19 @@
1
+ // A configured bucket always wins, even in dev (unlike the encryption stage-bypass):
2
+ // a deployed Lambda writes artifacts and the router reads them in different containers,
3
+ // so routing dev to ephemeral /tmp would silently lose them.
4
+ const { ArtifactRepositoryS3 } = require('./artifact-repository-s3');
5
+ const { ArtifactRepositoryLocal } = require('./artifact-repository-local');
6
+
7
+ function createArtifactRepository() {
8
+ const bucket = process.env.REPORT_ARTIFACT_BUCKET;
9
+ if (bucket) {
10
+ return new ArtifactRepositoryS3({ bucket });
11
+ }
12
+ return new ArtifactRepositoryLocal();
13
+ }
14
+
15
+ module.exports = {
16
+ createArtifactRepository,
17
+ ArtifactRepositoryS3,
18
+ ArtifactRepositoryLocal,
19
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Port for report artifacts (non-JSON output like CSV, PDF). Implementations
3
+ * write bytes to a durable store and return a location reference the caller
4
+ * persists; signed URLs are minted on read.
5
+ */
6
+ class ArtifactRepositoryInterface {
7
+ /**
8
+ * @param {string} key - Caller-supplied, e.g. `reports/{executionId}/{name}-{stamp}.{ext}`.
9
+ * @returns {Promise<{bucket: string, key: string}>} Location reference.
10
+ */
11
+ async put(key, body, contentType) {
12
+ throw new Error('ArtifactRepositoryInterface.put not implemented');
13
+ }
14
+
15
+ /**
16
+ * @param {{bucket: string, key: string}|string} ref - Reference from put(), or a bare key.
17
+ */
18
+ async signedUrl(ref, { expiresIn } = {}) {
19
+ throw new Error('ArtifactRepositoryInterface.signedUrl not implemented');
20
+ }
21
+
22
+ async get(key) {
23
+ throw new Error('ArtifactRepositoryInterface.get not implemented');
24
+ }
25
+ }
26
+
27
+ module.exports = { ArtifactRepositoryInterface };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Dev/test artifact adapter: writes to a local directory and returns file:// URLs.
3
+ * Selected by the factory only when no object store is configured (no REPORT_ARTIFACT_BUCKET).
4
+ */
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+ const {
9
+ ArtifactRepositoryInterface,
10
+ } = require('./artifact-repository-interface');
11
+
12
+ class ArtifactRepositoryLocal extends ArtifactRepositoryInterface {
13
+ constructor({ baseDir } = {}) {
14
+ super();
15
+ this.baseDir =
16
+ baseDir ||
17
+ process.env.REPORT_ARTIFACT_DIR ||
18
+ path.join(os.tmpdir(), 'frigg-report-artifacts');
19
+ }
20
+
21
+ _pathFor(key) {
22
+ return path.join(this.baseDir, key);
23
+ }
24
+
25
+ async put(key, body, _contentType) {
26
+ const filePath = this._pathFor(key);
27
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
28
+ await fs.promises.writeFile(filePath, body);
29
+ return { bucket: this.baseDir, key };
30
+ }
31
+
32
+ async signedUrl(ref, _options = {}) {
33
+ const key = ref?.key || ref;
34
+ return `file://${this._pathFor(key)}`;
35
+ }
36
+
37
+ async get(key) {
38
+ return fs.promises.readFile(this._pathFor(key), 'utf8');
39
+ }
40
+ }
41
+
42
+ module.exports = { ArtifactRepositoryLocal };
@@ -0,0 +1,61 @@
1
+ // aws-sdk modules are lazy-required so importing this file (and the factory that
2
+ // references it) never forces the SDK to load for contexts that only need the local adapter.
3
+ const {
4
+ ArtifactRepositoryInterface,
5
+ } = require('./artifact-repository-interface');
6
+
7
+ class ArtifactRepositoryS3 extends ArtifactRepositoryInterface {
8
+ constructor({ bucket, region, s3Client } = {}) {
9
+ super();
10
+ this.bucket = bucket || process.env.REPORT_ARTIFACT_BUCKET;
11
+ this.region = region || process.env.AWS_REGION || 'us-east-1';
12
+ this._s3Client = s3Client || null;
13
+ }
14
+
15
+ _getClient() {
16
+ if (!this._s3Client) {
17
+ const { S3Client } = require('@aws-sdk/client-s3');
18
+ this._s3Client = new S3Client({ region: this.region });
19
+ }
20
+ return this._s3Client;
21
+ }
22
+
23
+ async put(key, body, contentType) {
24
+ if (!this.bucket) {
25
+ throw new Error(
26
+ 'REPORT_ARTIFACT_BUCKET is not configured; cannot store report artifact'
27
+ );
28
+ }
29
+ const { PutObjectCommand } = require('@aws-sdk/client-s3');
30
+ // No ACL set: the bucket blocks public access, so objects stay private.
31
+ await this._getClient().send(
32
+ new PutObjectCommand({
33
+ Bucket: this.bucket,
34
+ Key: key,
35
+ Body: body,
36
+ ContentType: contentType,
37
+ ServerSideEncryption: 'AES256',
38
+ })
39
+ );
40
+ return { bucket: this.bucket, key };
41
+ }
42
+
43
+ async signedUrl(ref, { expiresIn = 3600 } = {}) {
44
+ const { GetObjectCommand } = require('@aws-sdk/client-s3');
45
+ const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
46
+ const bucket = ref?.bucket || this.bucket;
47
+ const key = ref?.key || ref;
48
+ const command = new GetObjectCommand({ Bucket: bucket, Key: key });
49
+ return getSignedUrl(this._getClient(), command, { expiresIn });
50
+ }
51
+
52
+ async get(key) {
53
+ const { GetObjectCommand } = require('@aws-sdk/client-s3');
54
+ const response = await this._getClient().send(
55
+ new GetObjectCommand({ Bucket: this.bucket, Key: key })
56
+ );
57
+ return response.Body.transformToString();
58
+ }
59
+ }
60
+
61
+ module.exports = { ArtifactRepositoryS3 };
@@ -0,0 +1,32 @@
1
+ const UNKNOWN_TYPE = 'unknown';
2
+
3
+ /**
4
+ * Type is derived from the related Entity.moduleName since Credential carries
5
+ * no type column. Counted once per distinct linked moduleName; none → `unknown`.
6
+ * Reads only the non-encrypted projection — never `data`/secrets.
7
+ */
8
+ function tallyActiveCredentialsByType(credentials = []) {
9
+ const counts = new Map();
10
+
11
+ for (const credential of credentials) {
12
+ const modules = new Set(
13
+ (credential?.entities || [])
14
+ .map((entity) => entity?.moduleName)
15
+ .filter(
16
+ (moduleName) => moduleName != null && moduleName !== ''
17
+ )
18
+ );
19
+ const types = modules.size > 0 ? [...modules] : [UNKNOWN_TYPE];
20
+
21
+ for (const type of types) {
22
+ counts.set(type, (counts.get(type) || 0) + 1);
23
+ }
24
+ }
25
+
26
+ return [...counts].map(([integrationType, count]) => ({
27
+ integrationType,
28
+ count,
29
+ }));
30
+ }
31
+
32
+ module.exports = { tallyActiveCredentialsByType, UNKNOWN_TYPE };
@@ -2,6 +2,7 @@ const { prisma } = require('../../database/prisma');
2
2
  const {
3
3
  toObjectId,
4
4
  fromObjectId,
5
+ findManyDrained,
5
6
  findOne,
6
7
  insertOne,
7
8
  updateOne,
@@ -10,6 +11,7 @@ const {
10
11
  const {
11
12
  CredentialRepositoryInterface,
12
13
  } = require('./credential-repository-interface');
14
+ const { tallyActiveCredentialsByType } = require('./credential-active-type');
13
15
  const {
14
16
  DocumentDBEncryptionService,
15
17
  } = require('../../database/documentdb-encryption-service');
@@ -238,6 +240,55 @@ class CredentialRepositoryDocumentDB extends CredentialRepositoryInterface {
238
240
  return this._mapCredential(decryptedCredential);
239
241
  }
240
242
 
243
+ /**
244
+ * Count credentials active since a timestamp, grouped by integration type.
245
+ * Projected raw reads only; the encrypted `data` is never fetched, so secrets are never decrypted for this read.
246
+ * @returns {Promise<Array<{ integrationType: string, count: number }>>}
247
+ */
248
+ async countActiveByType({ since } = {}) {
249
+ const filter = {};
250
+ // Coerce to Date: a raw string never matches the BSON date $gte (type bracketing).
251
+ if (since) filter.updatedAt = { $gte: new Date(since) };
252
+
253
+ // Drained: a deployment-wide scan must not truncate at DocumentDB's ~101-doc first batch.
254
+ const activeCredentials = await findManyDrained(
255
+ this.prisma,
256
+ 'Credential',
257
+ filter,
258
+ { projection: { _id: 1 } }
259
+ );
260
+
261
+ const credentialIds = activeCredentials
262
+ .map((doc) => toObjectId(doc._id))
263
+ .filter(Boolean);
264
+
265
+ const entities = credentialIds.length
266
+ ? await findManyDrained(
267
+ this.prisma,
268
+ 'Entity',
269
+ { credentialId: { $in: credentialIds } },
270
+ { projection: { credentialId: 1, moduleName: 1 } }
271
+ )
272
+ : [];
273
+
274
+ const entitiesByCredential = new Map();
275
+ for (const entity of entities) {
276
+ const key = fromObjectId(entity.credentialId);
277
+ if (!entitiesByCredential.has(key)) {
278
+ entitiesByCredential.set(key, []);
279
+ }
280
+ entitiesByCredential
281
+ .get(key)
282
+ .push({ moduleName: entity.moduleName });
283
+ }
284
+
285
+ const credentials = activeCredentials.map((doc) => ({
286
+ entities: entitiesByCredential.get(fromObjectId(doc._id)) || [],
287
+ }));
288
+
289
+ return tallyActiveCredentialsByType(credentials);
290
+ }
291
+
241
292
  _buildIdentifierFilter(identifiers) {
242
293
  const filter = {};
243
294
  if (identifiers._id || identifiers.id) {
@@ -93,6 +93,21 @@ class CredentialRepositoryInterface {
93
93
  'Method updateCredential must be implemented by subclass'
94
94
  );
95
95
  }
96
+
97
+ /**
98
+ * Count credentials active (updatedAt >= since) grouped by integration type.
99
+ * Reads ONLY non-encrypted fields — never the encrypted `data` JSON.
100
+ *
101
+ * @param {Object} params
102
+ * @param {Date} [params.since] - Lower bound on updatedAt
103
+ * @returns {Promise<Array<{ integrationType: string, count: number }>>}
104
+ * @abstract
105
+ */
106
+ async countActiveByType(/* { since } */) {
107
+ throw new Error(
108
+ 'Method countActiveByType must be implemented by subclass'
109
+ );
110
+ }
96
111
  }
97
112
 
98
113
  module.exports = { CredentialRepositoryInterface };