@friggframework/core 2.0.0--canary.702.ef0dc8c.0 → 2.0.0--canary.710.7cfcf12.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/CLAUDE.md +41 -1
- package/application/commands/README.md +17 -11
- package/application/commands/entity-commands.js +18 -60
- package/application/commands/integration-commands.js +63 -0
- package/database/documentdb-encryption-service.js +2 -33
- package/database/encryption/README.md +3 -3
- package/database/encryption/ciphertext-format.js +24 -0
- package/database/encryption/encryption-schema-registry.js +109 -29
- package/database/encryption/field-encryption-service.js +13 -16
- package/database/prisma.js +10 -1
- package/handlers/app-definition-loader.js +2 -9
- package/integrations/repositories/integration-repository-documentdb.js +32 -0
- package/integrations/repositories/integration-repository-interface.js +30 -0
- package/integrations/repositories/integration-repository-mongo.js +52 -0
- package/integrations/repositories/integration-repository-postgres.js +58 -0
- package/integrations/use-cases/add-entities-to-integration.js +92 -0
- package/integrations/use-cases/create-integration.js +57 -19
- package/integrations/use-cases/remove-entities-from-integration.js +70 -0
- package/integrations/utils/assert-entity-ids-input.js +42 -0
- package/jest-setup.js +13 -2
- package/jest-teardown.js +5 -2
- package/jest.config.js +4 -0
- package/jest.db.config.js +23 -0
- package/modules/utils/map-module-dto.js +5 -3
- package/modules/utils/to-credential-id.js +14 -0
- package/package.json +8 -8
- package/queues/queuer-util.js +6 -5
- package/user/tests/doubles/test-user-repository.js +1 -1
- package/user/use-cases/get-user-from-bearer-token.js +7 -2
package/database/prisma.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
const {
|
|
2
2
|
createEncryptionExtension,
|
|
3
3
|
} = require('./encryption/prisma-encryption-extension');
|
|
4
|
-
const {
|
|
4
|
+
const {
|
|
5
|
+
loadCustomEncryptionSchema,
|
|
6
|
+
EncryptionSchemaError,
|
|
7
|
+
} = require('./encryption/encryption-schema-registry');
|
|
5
8
|
const { logger } = require('./encryption/logger');
|
|
6
9
|
const { Cryptor } = require('../encrypt/Cryptor');
|
|
7
10
|
const config = require('./config');
|
|
@@ -125,6 +128,12 @@ const prismaClientSingleton = () => {
|
|
|
125
128
|
`Field-level encryption enabled using ${encryptionConfig.method.toUpperCase()}`
|
|
126
129
|
);
|
|
127
130
|
} catch (error) {
|
|
131
|
+
// An invalid encryption configuration must fail startup: carrying
|
|
132
|
+
// on without the extension would write encrypted fields in
|
|
133
|
+
// plaintext.
|
|
134
|
+
if (error instanceof EncryptionSchemaError) {
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
128
137
|
logger.error(
|
|
129
138
|
'Failed to initialize encryption extension:',
|
|
130
139
|
error
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
const { findNearestBackendPackageJson } = require('@friggframework/core/utils');
|
|
2
2
|
const path = require('node:path');
|
|
3
3
|
const fs = require('fs-extra');
|
|
4
|
-
const {
|
|
5
|
-
applyAppDefinitionDefaults,
|
|
6
|
-
} = require('@friggframework/schemas/app-definition');
|
|
7
4
|
const { resolveTelemetryConfig } = require('../telemetry/telemetry-config');
|
|
8
5
|
const { registerDeniedKeys } = require('../logs/denied-keys');
|
|
9
6
|
const {
|
|
@@ -36,9 +33,7 @@ function registerCredentialLogKeys(appDefinition, integrations) {
|
|
|
36
33
|
* @function loadAppDefinition
|
|
37
34
|
* @description Searches for the nearest backend package.json, loads the corresponding index.js file,
|
|
38
35
|
* and extracts the application definition containing integrations and user configuration.
|
|
39
|
-
*
|
|
40
|
-
* `userConfig` always holds a complete user config, also when the app omits `user`.
|
|
41
|
-
* @returns {{integrations: Array<object>, userConfig: object, adminScripts: Array<object>, reports: Array<object>, admin: object, telemetry: object, logging: object | null}} An object containing the application definition.
|
|
36
|
+
* @returns {{integrations: Array<object>, userConfig: object | null, adminScripts: Array<object>, reports: Array<object>, admin: object, telemetry: object, logging: object | null}} An object containing the application definition.
|
|
42
37
|
* @throws {Error} Throws error if backend package.json cannot be found.
|
|
43
38
|
* @throws {Error} Throws error if index.js file cannot be found in the backend directory.
|
|
44
39
|
* @example
|
|
@@ -58,9 +53,7 @@ function loadAppDefinition() {
|
|
|
58
53
|
}
|
|
59
54
|
|
|
60
55
|
const backendJsFile = require(backendFilePath);
|
|
61
|
-
|
|
62
|
-
// default in app-definition.schema.json is the value core sees.
|
|
63
|
-
const appDefinition = applyAppDefinitionDefaults(backendJsFile.Definition);
|
|
56
|
+
const appDefinition = backendJsFile.Definition;
|
|
64
57
|
|
|
65
58
|
const {
|
|
66
59
|
integrations = [],
|
|
@@ -164,6 +164,38 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
|
|
|
164
164
|
return this._mapIntegration(created);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
async addEntitiesToIntegration(integrationId, entityIds) {
|
|
168
|
+
const objectId = toObjectId(integrationId);
|
|
169
|
+
if (!objectId) return null;
|
|
170
|
+
const result = await updateOne(
|
|
171
|
+
this.prisma,
|
|
172
|
+
'Integration',
|
|
173
|
+
{ _id: objectId },
|
|
174
|
+
{
|
|
175
|
+
$addToSet: { entityIds: { $each: toObjectIdArray(entityIds) } },
|
|
176
|
+
$set: { updatedAt: { $date: new Date().toISOString() } },
|
|
177
|
+
}
|
|
178
|
+
);
|
|
179
|
+
if (!result?.n) return null;
|
|
180
|
+
return this.findIntegrationById(integrationId);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async removeEntitiesFromIntegration(integrationId, entityIds) {
|
|
184
|
+
const objectId = toObjectId(integrationId);
|
|
185
|
+
if (!objectId) return null;
|
|
186
|
+
const result = await updateOne(
|
|
187
|
+
this.prisma,
|
|
188
|
+
'Integration',
|
|
189
|
+
{ _id: objectId },
|
|
190
|
+
{
|
|
191
|
+
$pullAll: { entityIds: toObjectIdArray(entityIds) },
|
|
192
|
+
$set: { updatedAt: { $date: new Date().toISOString() } },
|
|
193
|
+
}
|
|
194
|
+
);
|
|
195
|
+
if (!result?.n) return null;
|
|
196
|
+
return this.findIntegrationById(integrationId);
|
|
197
|
+
}
|
|
198
|
+
|
|
167
199
|
async findIntegrationByUserId(userId) {
|
|
168
200
|
const objectId = toObjectId(userId);
|
|
169
201
|
if (!objectId) return null;
|
|
@@ -184,6 +184,36 @@ class IntegrationRepositoryInterface {
|
|
|
184
184
|
'Method findIntegrationsByEntityId must be implemented by subclass'
|
|
185
185
|
);
|
|
186
186
|
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Attach entities to an existing integration. Entities that are already
|
|
190
|
+
* attached are left as they are.
|
|
191
|
+
*
|
|
192
|
+
* @param {string|number} integrationId - Integration ID
|
|
193
|
+
* @param {Array<string|number>} entityIds - Entity IDs to attach
|
|
194
|
+
* @returns {Promise<Object|null>} Updated integration, or null when the integration does not exist
|
|
195
|
+
* @abstract
|
|
196
|
+
*/
|
|
197
|
+
async addEntitiesToIntegration(integrationId, entityIds) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
'Method addEntitiesToIntegration must be implemented by subclass'
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Detach entities from an integration. Entities that are not attached
|
|
205
|
+
* are ignored; the entities themselves are not deleted.
|
|
206
|
+
*
|
|
207
|
+
* @param {string|number} integrationId - Integration ID
|
|
208
|
+
* @param {Array<string|number>} entityIds - Entity IDs to detach
|
|
209
|
+
* @returns {Promise<Object|null>} Updated integration, or null when the integration does not exist
|
|
210
|
+
* @abstract
|
|
211
|
+
*/
|
|
212
|
+
async removeEntitiesFromIntegration(integrationId, entityIds) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
'Method removeEntitiesFromIntegration must be implemented by subclass'
|
|
215
|
+
);
|
|
216
|
+
}
|
|
187
217
|
}
|
|
188
218
|
|
|
189
219
|
module.exports = { IntegrationRepositoryInterface };
|
|
@@ -338,6 +338,58 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
|
|
|
338
338
|
};
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Attach entities to an existing integration. Entities already attached
|
|
343
|
+
* are left as they are.
|
|
344
|
+
*
|
|
345
|
+
* @param {string} integrationId - Integration ID
|
|
346
|
+
* @param {Array<string>} entityIds - Entity IDs to attach
|
|
347
|
+
* @returns {Promise<Object|null>} Updated integration, or null when the integration does not exist
|
|
348
|
+
*/
|
|
349
|
+
async addEntitiesToIntegration(integrationId, entityIds) {
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
await this.prisma.integration.update({
|
|
353
|
+
where: { id: integrationId },
|
|
354
|
+
data: {
|
|
355
|
+
entities: {
|
|
356
|
+
connect: entityIds.map((id) => ({ id })),
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
} catch (error) {
|
|
361
|
+
if (error.code === 'P2025') return null;
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
return this.findIntegrationById(integrationId);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Detach entities from an integration. Entities that are not attached
|
|
369
|
+
* are ignored. The entities themselves are not deleted.
|
|
370
|
+
*
|
|
371
|
+
* @param {string} integrationId - Integration ID
|
|
372
|
+
* @param {Array<string>} entityIds - Entity IDs to detach
|
|
373
|
+
* @returns {Promise<Object|null>} Updated integration, or null when the integration does not exist
|
|
374
|
+
*/
|
|
375
|
+
async removeEntitiesFromIntegration(integrationId, entityIds) {
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
await this.prisma.integration.update({
|
|
379
|
+
where: { id: integrationId },
|
|
380
|
+
data: {
|
|
381
|
+
entities: {
|
|
382
|
+
disconnect: entityIds.map((id) => ({ id })),
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
} catch (error) {
|
|
387
|
+
if (error.code === 'P2025') return null;
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
return this.findIntegrationById(integrationId);
|
|
391
|
+
}
|
|
392
|
+
|
|
341
393
|
/**
|
|
342
394
|
* Find integration by user ID (returns single integration)
|
|
343
395
|
* Replaces: IntegrationModel.findOne({ user: userId }).populate('entities')
|
|
@@ -365,6 +365,64 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
|
|
|
365
365
|
};
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Attach entities to an existing integration. Entities already attached
|
|
370
|
+
* are left as they are.
|
|
371
|
+
*
|
|
372
|
+
* @param {string} integrationId - Integration ID (string from application layer)
|
|
373
|
+
* @param {Array<string>} entityIds - Entity IDs to attach
|
|
374
|
+
* @returns {Promise<Object|null>} Updated integration, or null when the integration does not exist
|
|
375
|
+
*/
|
|
376
|
+
async addEntitiesToIntegration(integrationId, entityIds) {
|
|
377
|
+
const intId = this._convertId(integrationId);
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
await this.prisma.integration.update({
|
|
381
|
+
where: { id: intId },
|
|
382
|
+
data: {
|
|
383
|
+
entities: {
|
|
384
|
+
connect: entityIds.map((id) => ({
|
|
385
|
+
id: this._convertId(id),
|
|
386
|
+
})),
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if (error.code === 'P2025') return null;
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
return this.findIntegrationById(integrationId);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Detach entities from an integration. Entities that are not attached
|
|
399
|
+
* are ignored. The entities themselves are not deleted.
|
|
400
|
+
*
|
|
401
|
+
* @param {string} integrationId - Integration ID (string from application layer)
|
|
402
|
+
* @param {Array<string>} entityIds - Entity IDs to detach
|
|
403
|
+
* @returns {Promise<Object|null>} Updated integration, or null when the integration does not exist
|
|
404
|
+
*/
|
|
405
|
+
async removeEntitiesFromIntegration(integrationId, entityIds) {
|
|
406
|
+
const intId = this._convertId(integrationId);
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
await this.prisma.integration.update({
|
|
410
|
+
where: { id: intId },
|
|
411
|
+
data: {
|
|
412
|
+
entities: {
|
|
413
|
+
disconnect: entityIds.map((id) => ({
|
|
414
|
+
id: this._convertId(id),
|
|
415
|
+
})),
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if (error.code === 'P2025') return null;
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
423
|
+
return this.findIntegrationById(integrationId);
|
|
424
|
+
}
|
|
425
|
+
|
|
368
426
|
/**
|
|
369
427
|
* Find integration by user ID (returns single integration)
|
|
370
428
|
*
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const {
|
|
2
|
+
assertEntityIdsInput,
|
|
3
|
+
integrationNotFound,
|
|
4
|
+
loadIntegration,
|
|
5
|
+
} = require('../utils/assert-entity-ids-input');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Attaches entities to an existing integration, e.g. when a user grants
|
|
9
|
+
* access to more accounts after the integration was created. Every entity
|
|
10
|
+
* must exist, belong to the integration's user, and be of a module the
|
|
11
|
+
* integration already holds (so hydration can load it). Entities that are
|
|
12
|
+
* already attached are left as they are.
|
|
13
|
+
* @class AddEntitiesToIntegration
|
|
14
|
+
*/
|
|
15
|
+
class AddEntitiesToIntegration {
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object} params
|
|
18
|
+
* @param {import('../repositories/integration-repository-interface').IntegrationRepositoryInterface} params.integrationRepository
|
|
19
|
+
* @param {import('../../modules/repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
|
|
20
|
+
*/
|
|
21
|
+
constructor({ integrationRepository, moduleRepository }) {
|
|
22
|
+
this.integrationRepository = integrationRepository;
|
|
23
|
+
this.moduleRepository = moduleRepository;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} integrationId
|
|
28
|
+
* @param {string[]} entityIds
|
|
29
|
+
* @returns {Promise<Object>} The updated integration record.
|
|
30
|
+
*/
|
|
31
|
+
async execute(integrationId, entityIds) {
|
|
32
|
+
assertEntityIdsInput(integrationId, entityIds);
|
|
33
|
+
|
|
34
|
+
const integration = await loadIntegration(
|
|
35
|
+
this.integrationRepository,
|
|
36
|
+
integrationId
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const entities = await this.moduleRepository.findEntitiesByIds(
|
|
40
|
+
entityIds
|
|
41
|
+
);
|
|
42
|
+
const foundIds = new Set(entities.map((e) => String(e.id)));
|
|
43
|
+
const missing = entityIds.filter((id) => !foundIds.has(String(id)));
|
|
44
|
+
if (missing.length > 0) {
|
|
45
|
+
const error = new Error(
|
|
46
|
+
`Entities not found: ${missing.join(', ')}`
|
|
47
|
+
);
|
|
48
|
+
error.code = 'ENTITIES_NOT_FOUND';
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const foreign = entities.filter(
|
|
53
|
+
(e) => String(e.userId) !== String(integration.userId)
|
|
54
|
+
);
|
|
55
|
+
if (foreign.length > 0) {
|
|
56
|
+
const error = new Error(
|
|
57
|
+
`Entities do not belong to the integration's user: ${foreign
|
|
58
|
+
.map((e) => e.id)
|
|
59
|
+
.join(', ')}`
|
|
60
|
+
);
|
|
61
|
+
error.code = 'ENTITY_USER_MISMATCH';
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const attached = await this.moduleRepository.findEntitiesByIds(
|
|
66
|
+
integration.entitiesIds ?? []
|
|
67
|
+
);
|
|
68
|
+
const heldModules = new Set(attached.map((e) => e.moduleName));
|
|
69
|
+
const otherModules = entities.filter(
|
|
70
|
+
(e) => !heldModules.has(e.moduleName)
|
|
71
|
+
);
|
|
72
|
+
if (otherModules.length > 0) {
|
|
73
|
+
const error = new Error(
|
|
74
|
+
`Entities belong to a module the integration does not hold: ${otherModules
|
|
75
|
+
.map((e) => `${e.id} (${e.moduleName})`)
|
|
76
|
+
.join(', ')}`
|
|
77
|
+
);
|
|
78
|
+
error.code = 'ENTITY_MODULE_MISMATCH';
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const updated =
|
|
83
|
+
await this.integrationRepository.addEntitiesToIntegration(
|
|
84
|
+
integrationId,
|
|
85
|
+
entityIds
|
|
86
|
+
);
|
|
87
|
+
if (!updated) throw integrationNotFound(integrationId);
|
|
88
|
+
return updated;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { AddEntitiesToIntegration };
|
|
@@ -62,7 +62,7 @@ class CreateIntegration {
|
|
|
62
62
|
|
|
63
63
|
// A concurrent request may have created a matching row between the
|
|
64
64
|
// lookup above and this insert; re-check before any side effects run.
|
|
65
|
-
const survivor = await this.
|
|
65
|
+
const survivor = await this._findSurvivor(
|
|
66
66
|
userId,
|
|
67
67
|
config?.type,
|
|
68
68
|
entities
|
|
@@ -135,30 +135,68 @@ class CreateIntegration {
|
|
|
135
135
|
return mapIntegrationClassToIntegrationDTO(integrationInstance);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
async
|
|
138
|
+
async _findMatches(userId, type, entityIds) {
|
|
139
139
|
const candidates =
|
|
140
140
|
await this.integrationRepository.findIntegrationsByUserId(userId);
|
|
141
|
-
const target =
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
.
|
|
155
|
-
|
|
141
|
+
const target = new Set((entityIds ?? []).map(String));
|
|
142
|
+
|
|
143
|
+
const live = candidates.filter(
|
|
144
|
+
(integration) =>
|
|
145
|
+
// An IN_DELETION row is either mid-teardown or a zombie left
|
|
146
|
+
// behind by a failed deleteIntegrationById call — never a live
|
|
147
|
+
// integration. Treating it as a duplicate would hand a
|
|
148
|
+
// reinstall attempt a row nothing will ever move out of
|
|
149
|
+
// IN_DELETION, silently discarded by the queue worker forever.
|
|
150
|
+
// Let a fresh create proceed instead; the zombie is cleaned up
|
|
151
|
+
// out-of-band, matching how teardown failures are already
|
|
152
|
+
// handled here.
|
|
153
|
+
integration.status !== 'IN_DELETION' &&
|
|
154
|
+
integration.config?.type === type
|
|
155
|
+
);
|
|
156
|
+
const entitySet = (integration) =>
|
|
157
|
+
new Set((integration.entitiesIds ?? []).map(String));
|
|
158
|
+
|
|
159
|
+
const exact = live.filter((integration) => {
|
|
160
|
+
const current = entitySet(integration);
|
|
156
161
|
return (
|
|
157
|
-
current.
|
|
158
|
-
|
|
162
|
+
current.size === target.size &&
|
|
163
|
+
[...target].every((id) => current.has(id))
|
|
159
164
|
);
|
|
160
165
|
});
|
|
161
166
|
|
|
167
|
+
// Entities may have been attached after create (addEntitiesToIntegration),
|
|
168
|
+
// so a reconnect that sends the original entities must still find the
|
|
169
|
+
// integration that now holds them, instead of creating a second one
|
|
170
|
+
// that would share those entities.
|
|
171
|
+
const supersets = live.filter((integration) => {
|
|
172
|
+
const current = entitySet(integration);
|
|
173
|
+
return [...target].every((id) => current.has(id));
|
|
174
|
+
});
|
|
175
|
+
return { exact, supersets: target.size ? supersets : [] };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async _findDuplicate(userId, type, entityIds) {
|
|
179
|
+
const { exact, supersets } = await this._findMatches(
|
|
180
|
+
userId,
|
|
181
|
+
type,
|
|
182
|
+
entityIds
|
|
183
|
+
);
|
|
184
|
+
if (exact.length) return this._pickOldest(exact);
|
|
185
|
+
return supersets.length ? this._pickOldest(supersets) : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// After an insert the new row is itself an exact match, so the survivor
|
|
189
|
+
// is the oldest of every exact or superset match (the new row included):
|
|
190
|
+
// an older integration that already holds these entities always wins.
|
|
191
|
+
async _findSurvivor(userId, type, entityIds) {
|
|
192
|
+
const { exact, supersets } = await this._findMatches(
|
|
193
|
+
userId,
|
|
194
|
+
type,
|
|
195
|
+
entityIds
|
|
196
|
+
);
|
|
197
|
+
// supersets includes the exact matches; it is empty only for an
|
|
198
|
+
// empty entity set, where exact matching alone applies.
|
|
199
|
+
const matches = supersets.length ? supersets : exact;
|
|
162
200
|
return matches.length ? this._pickOldest(matches) : null;
|
|
163
201
|
}
|
|
164
202
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const {
|
|
2
|
+
assertEntityIdsInput,
|
|
3
|
+
integrationNotFound,
|
|
4
|
+
loadIntegration,
|
|
5
|
+
} = require('../utils/assert-entity-ids-input');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Detaches entities from an integration. Entities that are not attached are
|
|
9
|
+
* ignored, and detached entities are not deleted. Every module the
|
|
10
|
+
* integration holds must keep at least one entity, so the integration can
|
|
11
|
+
* still hydrate each of its modules.
|
|
12
|
+
* @class RemoveEntitiesFromIntegration
|
|
13
|
+
*/
|
|
14
|
+
class RemoveEntitiesFromIntegration {
|
|
15
|
+
/**
|
|
16
|
+
* @param {Object} params
|
|
17
|
+
* @param {import('../repositories/integration-repository-interface').IntegrationRepositoryInterface} params.integrationRepository
|
|
18
|
+
* @param {import('../../modules/repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
|
|
19
|
+
*/
|
|
20
|
+
constructor({ integrationRepository, moduleRepository }) {
|
|
21
|
+
this.integrationRepository = integrationRepository;
|
|
22
|
+
this.moduleRepository = moduleRepository;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} integrationId
|
|
27
|
+
* @param {string[]} entityIds
|
|
28
|
+
* @returns {Promise<Object>} The updated integration record.
|
|
29
|
+
*/
|
|
30
|
+
async execute(integrationId, entityIds) {
|
|
31
|
+
assertEntityIdsInput(integrationId, entityIds);
|
|
32
|
+
|
|
33
|
+
const integration = await loadIntegration(
|
|
34
|
+
this.integrationRepository,
|
|
35
|
+
integrationId
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const removing = new Set(entityIds.map(String));
|
|
39
|
+
const attached = await this.moduleRepository.findEntitiesByIds(
|
|
40
|
+
integration.entitiesIds ?? []
|
|
41
|
+
);
|
|
42
|
+
const emptied = [...new Set(attached.map((e) => e.moduleName))].filter(
|
|
43
|
+
(moduleName) =>
|
|
44
|
+
!attached.some(
|
|
45
|
+
(e) =>
|
|
46
|
+
e.moduleName === moduleName &&
|
|
47
|
+
!removing.has(String(e.id))
|
|
48
|
+
)
|
|
49
|
+
);
|
|
50
|
+
if (emptied.length > 0) {
|
|
51
|
+
const error = new Error(
|
|
52
|
+
`Removing these entities would leave no entity for module(s): ${emptied.join(
|
|
53
|
+
', '
|
|
54
|
+
)}`
|
|
55
|
+
);
|
|
56
|
+
error.code = 'LAST_ENTITY_OF_MODULE';
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const updated =
|
|
61
|
+
await this.integrationRepository.removeEntitiesFromIntegration(
|
|
62
|
+
integrationId,
|
|
63
|
+
entityIds
|
|
64
|
+
);
|
|
65
|
+
if (!updated) throw integrationNotFound(integrationId);
|
|
66
|
+
return updated;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { RemoveEntitiesFromIntegration };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throws INVALID_INTEGRATION_DATA unless an integration id and a non-empty
|
|
3
|
+
* list of entity ids are given.
|
|
4
|
+
* @param {string} integrationId
|
|
5
|
+
* @param {string[]} entityIds
|
|
6
|
+
*/
|
|
7
|
+
function assertEntityIdsInput(integrationId, entityIds) {
|
|
8
|
+
if (!integrationId || !Array.isArray(entityIds) || entityIds.length === 0) {
|
|
9
|
+
const error = new Error(
|
|
10
|
+
'integrationId and a non-empty entityIds array are required'
|
|
11
|
+
);
|
|
12
|
+
error.code = 'INVALID_INTEGRATION_DATA';
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function integrationNotFound(integrationId) {
|
|
18
|
+
const error = new Error(`Integration ${integrationId} not found`);
|
|
19
|
+
error.code = 'INTEGRATION_NOT_FOUND';
|
|
20
|
+
return error;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Loads an integration, mapping the repositories' "not found" error to
|
|
25
|
+
* INTEGRATION_NOT_FOUND. Any other failure (connection, invalid id) is
|
|
26
|
+
* rethrown unchanged.
|
|
27
|
+
* @param {Object} integrationRepository
|
|
28
|
+
* @param {string} integrationId
|
|
29
|
+
* @returns {Promise<Object>} The integration record.
|
|
30
|
+
*/
|
|
31
|
+
async function loadIntegration(integrationRepository, integrationId) {
|
|
32
|
+
try {
|
|
33
|
+
return await integrationRepository.findIntegrationById(integrationId);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (/^Integration with id .* not found$/.test(error?.message)) {
|
|
36
|
+
throw integrationNotFound(integrationId);
|
|
37
|
+
}
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { assertEntityIdsInput, integrationNotFound, loadIntegration };
|
package/jest-setup.js
CHANGED
|
@@ -1,2 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// Core's unit suites must not need a database, so this global setup only
|
|
2
|
+
// sets the test environment. It deliberately does not start an in-memory
|
|
3
|
+
// MongoDB (as @friggframework/test's globalSetup does): nothing in the default
|
|
4
|
+
// suite talks to a live server, and a binary that failed to start used to stop
|
|
5
|
+
// every core suite from running. Suites that need a real database are
|
|
6
|
+
// `*.db.test.js` files, run by `npm run test:db` (see jest.db.config.js).
|
|
7
|
+
const { overrideEnvironment } = require('@friggframework/test');
|
|
8
|
+
|
|
9
|
+
module.exports = async function () {
|
|
10
|
+
if (!process.env.STAGE) {
|
|
11
|
+
overrideEnvironment({ STAGE: 'dev' });
|
|
12
|
+
}
|
|
13
|
+
};
|
package/jest-teardown.js
CHANGED
package/jest.config.js
CHANGED
|
@@ -20,4 +20,8 @@ module.exports = {
|
|
|
20
20
|
globalTeardown: './jest-teardown.js',
|
|
21
21
|
|
|
22
22
|
setupFilesAfterEnv: ['./logs/jest-logger-setup.js'],
|
|
23
|
+
|
|
24
|
+
// `*.db.test.js` suites talk to a real MongoDB replica set / PostgreSQL
|
|
25
|
+
// and run separately via `npm run test:db` (jest.db.config.js).
|
|
26
|
+
testPathIgnorePatterns: ['/node_modules/', '\\.db\\.test\\.js$'],
|
|
23
27
|
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Database integration suites (`*.db.test.js`).
|
|
3
|
+
*
|
|
4
|
+
* These talk to real databases instead of mocking Prisma, so they are kept
|
|
5
|
+
* out of the default `npm test` run and executed with `npm run test:db`.
|
|
6
|
+
* They need generated Prisma clients (`npm run prisma:generate`) and:
|
|
7
|
+
* - a MongoDB replica set (Prisma requires one for MongoDB writes):
|
|
8
|
+
* FRIGG_TEST_MONGODB_URL, default
|
|
9
|
+
* mongodb://localhost:27017/frigg?replicaSet=rs0
|
|
10
|
+
* - PostgreSQL with the prisma-postgresql migrations applied:
|
|
11
|
+
* FRIGG_TEST_POSTGRES_URL, default
|
|
12
|
+
* postgresql://postgres:postgres@localhost:5432/frigg?schema=public
|
|
13
|
+
* The repository parity suites skip themselves unless their own URLs are
|
|
14
|
+
* set (INTEGRATION_ENTITIES_PARITY_* / QUERY_MAPPINGS_PARITY_*).
|
|
15
|
+
*/
|
|
16
|
+
const base = require('./jest.config');
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
...base,
|
|
20
|
+
coverageThreshold: undefined,
|
|
21
|
+
testMatch: ['**/*.db.test.js'],
|
|
22
|
+
testPathIgnorePatterns: ['/node_modules/'],
|
|
23
|
+
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const { toCredentialId } = require('./to-credential-id');
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* @param {import('../module').Module} moduleInstance
|
|
3
5
|
* Convert a Module domain instance to a plain DTO suitable for JSON responses.
|
|
@@ -10,9 +12,9 @@ function mapModuleClassToModuleDTO(moduleInstance) {
|
|
|
10
12
|
name: moduleInstance.name,
|
|
11
13
|
userId: moduleInstance.userId,
|
|
12
14
|
entity: moduleInstance.entity,
|
|
13
|
-
credentialId: moduleInstance.credential
|
|
14
|
-
type: moduleInstance.getName()
|
|
15
|
+
credentialId: toCredentialId(moduleInstance.credential),
|
|
16
|
+
type: moduleInstance.getName(),
|
|
15
17
|
};
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
module.exports = { mapModuleClassToModuleDTO };
|
|
20
|
+
module.exports = { mapModuleClassToModuleDTO };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the credential id from an entity's or module's `credential`, which
|
|
3
|
+
* repositories return as the decrypted credential object (or a bare id).
|
|
4
|
+
* @param {Object|string|number|null|undefined} credential
|
|
5
|
+
* @returns {string|null}
|
|
6
|
+
*/
|
|
7
|
+
function toCredentialId(credential) {
|
|
8
|
+
if (credential && typeof credential === 'object') {
|
|
9
|
+
return credential.id?.toString() ?? null;
|
|
10
|
+
}
|
|
11
|
+
return credential?.toString() ?? null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = { toCredentialId };
|