@friggframework/core 2.0.0--canary.702.3f342da.0 → 2.0.0--canary.689.f2e7e1d.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 +40 -0
- package/application/commands/integration-commands.js +63 -0
- package/handlers/app-definition-loader.js +2 -9
- package/infrastructure/scheduler/eventbridge-scheduler-adapter.js +13 -67
- 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 +91 -0
- package/integrations/use-cases/create-integration.js +35 -19
- package/integrations/use-cases/remove-entities-from-integration.js +69 -0
- package/integrations/utils/assert-entity-ids-input.js +23 -0
- package/package.json +6 -8
package/CLAUDE.md
CHANGED
|
@@ -206,11 +206,51 @@ packages/core/
|
|
|
206
206
|
- `delete-integration-for-user.js` - Remove integration
|
|
207
207
|
- `get-integration-instance.js` - Load integration with modules
|
|
208
208
|
- `load-integration-context.js` - Full integration context loading
|
|
209
|
+
- `add-entities-to-integration.js` / `remove-entities-from-integration.js` - Attach or detach entities on an existing integration
|
|
209
210
|
|
|
210
211
|
**Repositories**:
|
|
211
212
|
- `integration-repository-factory.js` - Creates database-specific repositories
|
|
212
213
|
- `integration-repository-mongo.js` - MongoDB implementation
|
|
213
214
|
- `integration-repository-postgres.js` - PostgreSQL implementation
|
|
215
|
+
- `addEntitiesToIntegration(integrationId, entityIds)` /
|
|
216
|
+
`removeEntitiesFromIntegration(integrationId, entityIds)` change the entity
|
|
217
|
+
set of an existing integration (all three adapters; `null` for an unknown
|
|
218
|
+
integration). Reach them through the `addEntitiesToIntegration` /
|
|
219
|
+
`removeEntitiesFromIntegration` commands. The add use case
|
|
220
|
+
(`add-entities-to-integration.js`) rejects unknown entities
|
|
221
|
+
(`ENTITIES_NOT_FOUND`, 404), entities of another user
|
|
222
|
+
(`ENTITY_USER_MISMATCH`, 403) and entities of a module the integration does
|
|
223
|
+
not already hold (`ENTITY_MODULE_MISMATCH`, 400). The remove use case
|
|
224
|
+
(`remove-entities-from-integration.js`) refuses to leave a module with no
|
|
225
|
+
entity (`LAST_ENTITY_OF_MODULE`, 409). Re-adding an attached entity or
|
|
226
|
+
removing a detached one changes nothing; a detached entity is not deleted.
|
|
227
|
+
`integration-repository-entities-parity.test.js` runs every adapter against
|
|
228
|
+
real databases when `INTEGRATION_ENTITIES_PARITY_MONGO_URL` /
|
|
229
|
+
`INTEGRATION_ENTITIES_PARITY_POSTGRES_URL` are set. The DocumentDB adapter
|
|
230
|
+
writes only `Integration.entityIds` (as its `createIntegration` does), not
|
|
231
|
+
`Entity.integrationIds`.
|
|
232
|
+
**One credential, many accounts**: when one credential covers several
|
|
233
|
+
provider accounts, give each account its own entity (externalId = account
|
|
234
|
+
id) and attach them all to one integration; webhooks then route with
|
|
235
|
+
`findIntegrationByEntityExternalId`. Traps:
|
|
236
|
+
(1) `IntegrationBase` keys modules by module name, so `this.<moduleKey>`
|
|
237
|
+
holds only one entity's module, and on the queue/webhook path
|
|
238
|
+
(`get-integration-instance.js`) `this.entities` holds entity **ids**, not
|
|
239
|
+
records. For per-account work (a webhook for one account, teardown of every
|
|
240
|
+
account in `onDelete`), load
|
|
241
|
+
`this.commands.loadIntegrationContextById(this.id)` and use
|
|
242
|
+
`context.modules`: one module per entity, each with its record on
|
|
243
|
+
`module.entity` (pick by `module.entity.externalId`).
|
|
244
|
+
(2) `findIntegrationByEntityExternalId` counts every entity with that
|
|
245
|
+
externalId, attached or not, across users: reuse an existing entity
|
|
246
|
+
(`findEntity`) when an account comes back, or delete it when it is revoked,
|
|
247
|
+
never create a second one.
|
|
248
|
+
(3) `createIntegration` reuses an existing integration of the same user
|
|
249
|
+
and type whose entities include all requested ones (an exact match wins),
|
|
250
|
+
so a reconnect that sends the original entities finds the integration that
|
|
251
|
+
now holds more.
|
|
252
|
+
(4) Every module's delegate is the integration: an auth failure on any
|
|
253
|
+
account marks the shared credential invalid and the integration ERROR.
|
|
214
254
|
- `integration-mapping-repository-*.js` - Mapping data persistence.
|
|
215
255
|
`queryMappings(integrationId, { where, orderBy, skip, take, omit })`
|
|
216
256
|
returns `{ mappings, total }`: one filtered, ordered page of an
|
|
@@ -29,6 +29,12 @@ const {
|
|
|
29
29
|
const {
|
|
30
30
|
PatchIntegrationConfig,
|
|
31
31
|
} = require('../../integrations/use-cases/patch-integration-config');
|
|
32
|
+
const {
|
|
33
|
+
AddEntitiesToIntegration,
|
|
34
|
+
} = require('../../integrations/use-cases/add-entities-to-integration');
|
|
35
|
+
const {
|
|
36
|
+
RemoveEntitiesFromIntegration,
|
|
37
|
+
} = require('../../integrations/use-cases/remove-entities-from-integration');
|
|
32
38
|
const {
|
|
33
39
|
getModulesDefinitionFromIntegrationClasses,
|
|
34
40
|
} = require('../../integrations/utils/map-integration-dto');
|
|
@@ -40,6 +46,11 @@ const ERROR_CODE_MAP = {
|
|
|
40
46
|
EXTERNAL_ID_REQUIRED: 400,
|
|
41
47
|
TYPE_REQUIRED: 400,
|
|
42
48
|
INTEGRATION_RECORD_NOT_FOUND: 404,
|
|
49
|
+
INVALID_INTEGRATION_DATA: 400,
|
|
50
|
+
ENTITIES_NOT_FOUND: 404,
|
|
51
|
+
ENTITY_USER_MISMATCH: 403,
|
|
52
|
+
ENTITY_MODULE_MISMATCH: 400,
|
|
53
|
+
LAST_ENTITY_OF_MODULE: 409,
|
|
43
54
|
};
|
|
44
55
|
|
|
45
56
|
function mapErrorToResponse(error) {
|
|
@@ -163,6 +174,17 @@ function createIntegrationCommands({ integrationClass } = {}) {
|
|
|
163
174
|
integrationRepository,
|
|
164
175
|
});
|
|
165
176
|
|
|
177
|
+
const addEntitiesToIntegrationUseCase = new AddEntitiesToIntegration({
|
|
178
|
+
integrationRepository,
|
|
179
|
+
moduleRepository,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const removeEntitiesFromIntegrationUseCase =
|
|
183
|
+
new RemoveEntitiesFromIntegration({
|
|
184
|
+
integrationRepository,
|
|
185
|
+
moduleRepository,
|
|
186
|
+
});
|
|
187
|
+
|
|
166
188
|
return {
|
|
167
189
|
findIntegrationById,
|
|
168
190
|
listIntegrations,
|
|
@@ -307,6 +329,47 @@ function createIntegrationCommands({ integrationClass } = {}) {
|
|
|
307
329
|
}
|
|
308
330
|
},
|
|
309
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Attach entities to an existing integration. Every entity must
|
|
334
|
+
* exist, belong to the integration's user, and be of a module the
|
|
335
|
+
* integration already holds; entities that are already attached are
|
|
336
|
+
* left as they are.
|
|
337
|
+
* @param {Object} params
|
|
338
|
+
* @param {string} params.integrationId - Integration ID
|
|
339
|
+
* @param {Array<string>} params.entityIds - Entity IDs to attach
|
|
340
|
+
* @returns {Promise<Object>} Updated integration
|
|
341
|
+
*/
|
|
342
|
+
async addEntitiesToIntegration({ integrationId, entityIds }) {
|
|
343
|
+
try {
|
|
344
|
+
return await addEntitiesToIntegrationUseCase.execute(
|
|
345
|
+
integrationId,
|
|
346
|
+
entityIds
|
|
347
|
+
);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
return mapErrorToResponse(error);
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Detach entities from an integration. Entities that are not
|
|
355
|
+
* attached are ignored; detached entities are not deleted. Each
|
|
356
|
+
* module must keep at least one entity.
|
|
357
|
+
* @param {Object} params
|
|
358
|
+
* @param {string} params.integrationId - Integration ID
|
|
359
|
+
* @param {Array<string>} params.entityIds - Entity IDs to detach
|
|
360
|
+
* @returns {Promise<Object>} Updated integration
|
|
361
|
+
*/
|
|
362
|
+
async removeEntitiesFromIntegration({ integrationId, entityIds }) {
|
|
363
|
+
try {
|
|
364
|
+
return await removeEntitiesFromIntegrationUseCase.execute(
|
|
365
|
+
integrationId,
|
|
366
|
+
entityIds
|
|
367
|
+
);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
return mapErrorToResponse(error);
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
|
|
310
373
|
/**
|
|
311
374
|
* Delete an integration by ID
|
|
312
375
|
* @param {string} integrationId - Integration ID to delete
|
|
@@ -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 = [],
|
|
@@ -11,65 +11,19 @@
|
|
|
11
11
|
* This adapter implements SchedulerServiceInterface for AWS EventBridge Scheduler.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
const {
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
const {
|
|
15
|
+
SchedulerClient,
|
|
16
|
+
CreateScheduleCommand,
|
|
17
|
+
DeleteScheduleCommand,
|
|
18
|
+
GetScheduleCommand,
|
|
19
|
+
ResourceNotFoundException,
|
|
20
|
+
} = require('@aws-sdk/client-scheduler');
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
* Lazily load the AWS EventBridge Scheduler SDK.
|
|
20
|
-
*
|
|
21
|
-
* Requiring `@aws-sdk/client-scheduler` at module top level would put the AWS
|
|
22
|
-
* SDK on the load path of every consumer of `@friggframework/core`, including
|
|
23
|
-
* those using the mock scheduler or no scheduler at all. Loading it here, on
|
|
24
|
-
* construction of the EventBridge adapter, keeps it out of the module-load
|
|
25
|
-
* path. The adapter calls it exactly once, in its constructor, and keeps the
|
|
26
|
-
* classes its methods need on the instance.
|
|
27
|
-
*
|
|
28
|
-
* Only a MODULE_NOT_FOUND for the scheduler SDK itself is rewritten into an
|
|
29
|
-
* actionable message; any other failure (including a missing transitive
|
|
30
|
-
* dependency) is rethrown unchanged so the real cause is not masked.
|
|
31
|
-
*
|
|
32
|
-
* @returns {object} The relevant exports from `@aws-sdk/client-scheduler`.
|
|
33
|
-
*/
|
|
34
|
-
function loadSchedulerSdk() {
|
|
35
|
-
try {
|
|
36
|
-
// Keep the literal specifier so bundlers (esbuild) can resolve it.
|
|
37
|
-
return require('@aws-sdk/client-scheduler');
|
|
38
|
-
} catch (error) {
|
|
39
|
-
const isSchedulerSdkMissing =
|
|
40
|
-
error &&
|
|
41
|
-
error.code === 'MODULE_NOT_FOUND' &&
|
|
42
|
-
typeof error.message === 'string' &&
|
|
43
|
-
error.message.includes(SCHEDULER_SDK);
|
|
44
|
-
if (isSchedulerSdkMissing) {
|
|
45
|
-
throw new Error(
|
|
46
|
-
`The EventBridge scheduler requires the "${SCHEDULER_SDK}" package, ` +
|
|
47
|
-
`which is not installed. Install it (\`npm install ${SCHEDULER_SDK}\`) ` +
|
|
48
|
-
'to use the EventBridge scheduler provider, or use the mock scheduler provider ' +
|
|
49
|
-
'(SCHEDULER_PROVIDER=mock) for local development.',
|
|
50
|
-
{ cause: error }
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
throw error;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
22
|
+
const { SchedulerServiceInterface } = require('./scheduler-service-interface');
|
|
56
23
|
|
|
57
24
|
class EventBridgeSchedulerAdapter extends SchedulerServiceInterface {
|
|
58
25
|
constructor({ region } = {}) {
|
|
59
26
|
super();
|
|
60
|
-
const {
|
|
61
|
-
SchedulerClient,
|
|
62
|
-
CreateScheduleCommand,
|
|
63
|
-
DeleteScheduleCommand,
|
|
64
|
-
GetScheduleCommand,
|
|
65
|
-
ResourceNotFoundException,
|
|
66
|
-
} = loadSchedulerSdk();
|
|
67
|
-
this.sdk = {
|
|
68
|
-
CreateScheduleCommand,
|
|
69
|
-
DeleteScheduleCommand,
|
|
70
|
-
GetScheduleCommand,
|
|
71
|
-
ResourceNotFoundException,
|
|
72
|
-
};
|
|
73
27
|
this.client = new SchedulerClient({
|
|
74
28
|
region: region || process.env.AWS_REGION || 'us-east-1',
|
|
75
29
|
});
|
|
@@ -88,12 +42,7 @@ class EventBridgeSchedulerAdapter extends SchedulerServiceInterface {
|
|
|
88
42
|
* @param {Object} params.payload - Message payload
|
|
89
43
|
* @returns {Promise<{scheduledJobId: string, scheduledAt: string}>}
|
|
90
44
|
*/
|
|
91
|
-
async scheduleOneTime({
|
|
92
|
-
scheduleName,
|
|
93
|
-
scheduleAt,
|
|
94
|
-
queueResourceId,
|
|
95
|
-
payload,
|
|
96
|
-
}) {
|
|
45
|
+
async scheduleOneTime({ scheduleName, scheduleAt, queueResourceId, payload }) {
|
|
97
46
|
if (!scheduleName) {
|
|
98
47
|
throw new Error('scheduleName is required');
|
|
99
48
|
}
|
|
@@ -110,11 +59,8 @@ class EventBridgeSchedulerAdapter extends SchedulerServiceInterface {
|
|
|
110
59
|
}
|
|
111
60
|
|
|
112
61
|
// Format date to AWS schedule expression (at(yyyy-mm-ddThh:mm:ss))
|
|
113
|
-
const scheduleExpression = `at(${scheduleAt
|
|
114
|
-
.toISOString()
|
|
115
|
-
.replace(/\.\d{3}Z$/, '')})`;
|
|
62
|
+
const scheduleExpression = `at(${scheduleAt.toISOString().replace(/\.\d{3}Z$/, '')})`;
|
|
116
63
|
|
|
117
|
-
const { CreateScheduleCommand } = this.sdk;
|
|
118
64
|
const command = new CreateScheduleCommand({
|
|
119
65
|
Name: scheduleName,
|
|
120
66
|
GroupName: this.scheduleGroupName,
|
|
@@ -161,7 +107,6 @@ class EventBridgeSchedulerAdapter extends SchedulerServiceInterface {
|
|
|
161
107
|
throw new Error('scheduleName is required');
|
|
162
108
|
}
|
|
163
109
|
|
|
164
|
-
const { DeleteScheduleCommand, ResourceNotFoundException } = this.sdk;
|
|
165
110
|
const command = new DeleteScheduleCommand({
|
|
166
111
|
Name: scheduleName,
|
|
167
112
|
GroupName: this.scheduleGroupName,
|
|
@@ -196,7 +141,6 @@ class EventBridgeSchedulerAdapter extends SchedulerServiceInterface {
|
|
|
196
141
|
throw new Error('scheduleName is required');
|
|
197
142
|
}
|
|
198
143
|
|
|
199
|
-
const { GetScheduleCommand, ResourceNotFoundException } = this.sdk;
|
|
200
144
|
const command = new GetScheduleCommand({
|
|
201
145
|
Name: scheduleName,
|
|
202
146
|
GroupName: this.scheduleGroupName,
|
|
@@ -209,7 +153,9 @@ class EventBridgeSchedulerAdapter extends SchedulerServiceInterface {
|
|
|
209
153
|
// Format: at(yyyy-mm-ddThh:mm:ss)
|
|
210
154
|
let scheduledAt = null;
|
|
211
155
|
if (response.ScheduleExpression) {
|
|
212
|
-
const match = response.ScheduleExpression.match(
|
|
156
|
+
const match = response.ScheduleExpression.match(
|
|
157
|
+
/^at\((.+)\)$/
|
|
158
|
+
);
|
|
213
159
|
if (match) {
|
|
214
160
|
scheduledAt = new Date(match[1] + 'Z').toISOString();
|
|
215
161
|
}
|
|
@@ -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,91 @@
|
|
|
1
|
+
const {
|
|
2
|
+
assertEntityIdsInput,
|
|
3
|
+
integrationNotFound,
|
|
4
|
+
} = require('../utils/assert-entity-ids-input');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Attaches entities to an existing integration, e.g. when a user grants
|
|
8
|
+
* access to more accounts after the integration was created. Every entity
|
|
9
|
+
* must exist, belong to the integration's user, and be of a module the
|
|
10
|
+
* integration already holds (so hydration can load it). Entities that are
|
|
11
|
+
* already attached are left as they are.
|
|
12
|
+
* @class AddEntitiesToIntegration
|
|
13
|
+
*/
|
|
14
|
+
class AddEntitiesToIntegration {
|
|
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 this.integrationRepository
|
|
34
|
+
.findIntegrationById(integrationId)
|
|
35
|
+
.catch(() => null);
|
|
36
|
+
if (!integration) throw integrationNotFound(integrationId);
|
|
37
|
+
|
|
38
|
+
const entities = await this.moduleRepository.findEntitiesByIds(
|
|
39
|
+
entityIds
|
|
40
|
+
);
|
|
41
|
+
const foundIds = new Set(entities.map((e) => String(e.id)));
|
|
42
|
+
const missing = entityIds.filter((id) => !foundIds.has(String(id)));
|
|
43
|
+
if (missing.length > 0) {
|
|
44
|
+
const error = new Error(
|
|
45
|
+
`Entities not found: ${missing.join(', ')}`
|
|
46
|
+
);
|
|
47
|
+
error.code = 'ENTITIES_NOT_FOUND';
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const foreign = entities.filter(
|
|
52
|
+
(e) => String(e.userId) !== String(integration.userId)
|
|
53
|
+
);
|
|
54
|
+
if (foreign.length > 0) {
|
|
55
|
+
const error = new Error(
|
|
56
|
+
`Entities do not belong to the integration's user: ${foreign
|
|
57
|
+
.map((e) => e.id)
|
|
58
|
+
.join(', ')}`
|
|
59
|
+
);
|
|
60
|
+
error.code = 'ENTITY_USER_MISMATCH';
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const attached = await this.moduleRepository.findEntitiesByIds(
|
|
65
|
+
integration.entitiesIds ?? []
|
|
66
|
+
);
|
|
67
|
+
const heldModules = new Set(attached.map((e) => e.moduleName));
|
|
68
|
+
const otherModules = entities.filter(
|
|
69
|
+
(e) => !heldModules.has(e.moduleName)
|
|
70
|
+
);
|
|
71
|
+
if (otherModules.length > 0) {
|
|
72
|
+
const error = new Error(
|
|
73
|
+
`Entities belong to a module the integration does not hold: ${otherModules
|
|
74
|
+
.map((e) => `${e.id} (${e.moduleName})`)
|
|
75
|
+
.join(', ')}`
|
|
76
|
+
);
|
|
77
|
+
error.code = 'ENTITY_MODULE_MISMATCH';
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const updated =
|
|
82
|
+
await this.integrationRepository.addEntitiesToIntegration(
|
|
83
|
+
integrationId,
|
|
84
|
+
entityIds
|
|
85
|
+
);
|
|
86
|
+
if (!updated) throw integrationNotFound(integrationId);
|
|
87
|
+
return updated;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { AddEntitiesToIntegration };
|
|
@@ -138,28 +138,44 @@ class CreateIntegration {
|
|
|
138
138
|
async _findDuplicate(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
|
-
|
|
162
|
-
|
|
166
|
+
if (exact.length) return this._pickOldest(exact);
|
|
167
|
+
|
|
168
|
+
// Entities may have been attached after create (addEntitiesToIntegration),
|
|
169
|
+
// so a reconnect that sends the original entities must still find the
|
|
170
|
+
// integration that now holds them, instead of creating a second one
|
|
171
|
+
// that would share those entities.
|
|
172
|
+
const supersets = live.filter((integration) => {
|
|
173
|
+
const current = entitySet(integration);
|
|
174
|
+
return [...target].every((id) => current.has(id));
|
|
175
|
+
});
|
|
176
|
+
return target.size && supersets.length
|
|
177
|
+
? this._pickOldest(supersets)
|
|
178
|
+
: null;
|
|
163
179
|
}
|
|
164
180
|
|
|
165
181
|
_pickOldest(records) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const {
|
|
2
|
+
assertEntityIdsInput,
|
|
3
|
+
integrationNotFound,
|
|
4
|
+
} = require('../utils/assert-entity-ids-input');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Detaches entities from an integration. Entities that are not attached are
|
|
8
|
+
* ignored, and detached entities are not deleted. Every module the
|
|
9
|
+
* integration holds must keep at least one entity, so the integration can
|
|
10
|
+
* still hydrate each of its modules.
|
|
11
|
+
* @class RemoveEntitiesFromIntegration
|
|
12
|
+
*/
|
|
13
|
+
class RemoveEntitiesFromIntegration {
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object} params
|
|
16
|
+
* @param {import('../repositories/integration-repository-interface').IntegrationRepositoryInterface} params.integrationRepository
|
|
17
|
+
* @param {import('../../modules/repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
|
|
18
|
+
*/
|
|
19
|
+
constructor({ integrationRepository, moduleRepository }) {
|
|
20
|
+
this.integrationRepository = integrationRepository;
|
|
21
|
+
this.moduleRepository = moduleRepository;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} integrationId
|
|
26
|
+
* @param {string[]} entityIds
|
|
27
|
+
* @returns {Promise<Object>} The updated integration record.
|
|
28
|
+
*/
|
|
29
|
+
async execute(integrationId, entityIds) {
|
|
30
|
+
assertEntityIdsInput(integrationId, entityIds);
|
|
31
|
+
|
|
32
|
+
const integration = await this.integrationRepository
|
|
33
|
+
.findIntegrationById(integrationId)
|
|
34
|
+
.catch(() => null);
|
|
35
|
+
if (!integration) throw integrationNotFound(integrationId);
|
|
36
|
+
|
|
37
|
+
const removing = new Set(entityIds.map(String));
|
|
38
|
+
const attached = await this.moduleRepository.findEntitiesByIds(
|
|
39
|
+
integration.entitiesIds ?? []
|
|
40
|
+
);
|
|
41
|
+
const emptied = [...new Set(attached.map((e) => e.moduleName))].filter(
|
|
42
|
+
(moduleName) =>
|
|
43
|
+
!attached.some(
|
|
44
|
+
(e) =>
|
|
45
|
+
e.moduleName === moduleName &&
|
|
46
|
+
!removing.has(String(e.id))
|
|
47
|
+
)
|
|
48
|
+
);
|
|
49
|
+
if (emptied.length > 0) {
|
|
50
|
+
const error = new Error(
|
|
51
|
+
`Removing these entities would leave no entity for module(s): ${emptied.join(
|
|
52
|
+
', '
|
|
53
|
+
)}`
|
|
54
|
+
);
|
|
55
|
+
error.code = 'LAST_ENTITY_OF_MODULE';
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const updated =
|
|
60
|
+
await this.integrationRepository.removeEntitiesFromIntegration(
|
|
61
|
+
integrationId,
|
|
62
|
+
entityIds
|
|
63
|
+
);
|
|
64
|
+
if (!updated) throw integrationNotFound(integrationId);
|
|
65
|
+
return updated;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { RemoveEntitiesFromIntegration };
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
module.exports = { assertEntityIdsInput, integrationNotFound };
|
package/package.json
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/core",
|
|
3
|
-
"description": "",
|
|
4
3
|
"prettier": "@friggframework/prettier-config",
|
|
5
|
-
"version": "2.0.0--canary.
|
|
4
|
+
"version": "2.0.0--canary.689.f2e7e1d.0",
|
|
6
5
|
"dependencies": {
|
|
7
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
8
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
9
8
|
"@aws-sdk/client-lambda": "^3.714.0",
|
|
10
9
|
"@aws-sdk/client-s3": "^3.588.0",
|
|
11
|
-
"@aws-sdk/client-scheduler": "^3.1153.0",
|
|
12
10
|
"@aws-sdk/client-sqs": "^3.588.0",
|
|
13
11
|
"@aws-sdk/client-ssm": "^3.588.0",
|
|
14
12
|
"@aws-sdk/s3-request-presigner": "^3.588.0",
|
|
15
|
-
"@friggframework/schemas": "2.0.0--canary.702.3f342da.0",
|
|
16
13
|
"@hapi/boom": "^10.0.1",
|
|
17
14
|
"@opentelemetry/api": "^1.9.1",
|
|
18
15
|
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
@@ -50,9 +47,9 @@
|
|
|
50
47
|
}
|
|
51
48
|
},
|
|
52
49
|
"devDependencies": {
|
|
53
|
-
"@friggframework/eslint-config": "2.0.0--canary.
|
|
54
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
55
|
-
"@friggframework/test": "2.0.0--canary.
|
|
50
|
+
"@friggframework/eslint-config": "2.0.0--canary.689.f2e7e1d.0",
|
|
51
|
+
"@friggframework/prettier-config": "2.0.0--canary.689.f2e7e1d.0",
|
|
52
|
+
"@friggframework/test": "2.0.0--canary.689.f2e7e1d.0",
|
|
56
53
|
"@prisma/client": "^6.19.3",
|
|
57
54
|
"@types/lodash": "4.17.15",
|
|
58
55
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -88,8 +85,9 @@
|
|
|
88
85
|
"url": "https://github.com/friggframework/frigg/issues"
|
|
89
86
|
},
|
|
90
87
|
"homepage": "https://github.com/friggframework/frigg#readme",
|
|
88
|
+
"description": "",
|
|
91
89
|
"publishConfig": {
|
|
92
90
|
"access": "public"
|
|
93
91
|
},
|
|
94
|
-
"gitHead": "
|
|
92
|
+
"gitHead": "f2e7e1d56cdcc9d6a267ff02714ad8fdad86051f"
|
|
95
93
|
}
|