@friggframework/core 2.0.0--canary.710.91495fa.0 → 2.0.0--canary.702.ef0dc8c.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 CHANGED
@@ -206,51 +206,11 @@ 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
210
209
 
211
210
  **Repositories**:
212
211
  - `integration-repository-factory.js` - Creates database-specific repositories
213
212
  - `integration-repository-mongo.js` - MongoDB implementation
214
213
  - `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.db.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.
254
214
  - `integration-mapping-repository-*.js` - Mapping data persistence.
255
215
  `queryMappings(integrationId, { where, orderBy, skip, take, omit })`
256
216
  returns `{ mappings, total }`: one filtered, ordered page of an
@@ -266,7 +226,7 @@ packages/core/
266
226
  mapping keys to leave out of the rows; never write such rows back. Path
267
227
  segments must match `^[A-Za-z_][A-Za-z0-9_]*$`. The PostgreSQL, MongoDB and
268
228
  DocumentDB adapters give the same pages and totals;
269
- `integration-mapping-repository-query-parity.db.test.js` checks that against
229
+ `integration-mapping-repository-query-parity.test.js` checks that against
270
230
  real databases when `QUERY_MAPPINGS_PARITY_MONGO_URL` /
271
231
  `QUERY_MAPPINGS_PARITY_POSTGRES_URL` are set. Two orderings still differ:
272
232
  strings compare by the database collation on PostgreSQL and by code point
@@ -67,10 +67,9 @@ class MyIntegration extends IntegrationBase {
67
67
  async hydrateFromExternalUser(externalUserId) {
68
68
  // Find integration context by external entity ID
69
69
  const result =
70
- await this.commands.findIntegrationContextByExternalEntityId({
71
- externalId: externalUserId,
72
- type: 'my-integration', // integration config.type to match
73
- });
70
+ await this.commands.findIntegrationContextByExternalEntityId(
71
+ externalUserId
72
+ );
74
73
 
75
74
  if (result.error) {
76
75
  return { error: result.error };
@@ -232,10 +231,9 @@ Manage integration records and load full integration contexts.
232
231
  ```javascript
233
232
  // Find integration context by external entity ID
234
233
  // Returns { context, error } where context includes record + hydrated modules
235
- const result = await commands.findIntegrationContextByExternalEntityId({
236
- externalId: 'external-user-or-workspace-id',
237
- type: 'my-integration', // integration config.type to match
238
- });
234
+ const result = await commands.findIntegrationContextByExternalEntityId(
235
+ 'external-user-or-workspace-id'
236
+ );
239
237
 
240
238
  if (!result.error) {
241
239
  integration.setIntegrationRecord(result.context);
@@ -301,10 +299,7 @@ try {
301
299
  For integration context operations, errors are returned in the result:
302
300
 
303
301
  ```javascript
304
- const result = await commands.findIntegrationContextByExternalEntityId({
305
- externalId: userId,
306
- type: 'my-integration',
307
- });
302
+ const result = await commands.findIntegrationContextByExternalEntityId(userId);
308
303
 
309
304
  if (result.error) {
310
305
  return { error: result.error };
@@ -354,10 +349,9 @@ const result =
354
349
  const { createFriggCommands } = require('@friggframework/core');
355
350
 
356
351
  this.commands = createFriggCommands({ integrationClass: MyIntegration });
357
- const result = await this.commands.findIntegrationContextByExternalEntityId({
358
- externalId: userId,
359
- type: 'my-integration',
360
- });
352
+ const result = await this.commands.findIntegrationContextByExternalEntityId(
353
+ userId
354
+ );
361
355
  ```
362
356
 
363
357
  ## Best Practices
@@ -439,7 +433,7 @@ if (!user) {
439
433
  | | `findEntitiesByIds(ids)` | Find multiple by IDs |
440
434
  | | `updateEntity(id, updates)` | Update entity properties |
441
435
  | | `deleteEntity(id)` | Delete entity |
442
- | **Integration** | `findIntegrationContextByExternalEntityId({ externalId, type })` | Load integration + modules by external ID |
436
+ | **Integration** | `findIntegrationContextByExternalEntityId(externalId)` | Load integration + modules by external ID |
443
437
  | | `loadIntegrationContextById(integrationId)` | Load integration + modules by ID |
444
438
 
445
439
  ## Support
@@ -1,24 +1,12 @@
1
1
  const {
2
2
  createModuleRepository,
3
3
  } = require('../../modules/repositories/module-repository-factory');
4
- const { toCredentialId } = require('../../modules/utils/to-credential-id');
5
4
 
6
5
  const ERROR_CODE_MAP = {
7
6
  ENTITY_NOT_FOUND: 404,
8
7
  INVALID_ENTITY_DATA: 400,
9
8
  };
10
9
 
11
- function toEntityDto(entity) {
12
- return {
13
- id: entity.id,
14
- userId: entity.userId,
15
- externalId: entity.externalId,
16
- name: entity.name,
17
- moduleName: entity.moduleName,
18
- credentialId: toCredentialId(entity.credential),
19
- };
20
- }
21
-
22
10
  function mapErrorToResponse(error) {
23
11
  const status = ERROR_CODE_MAP[error?.code] || 500;
24
12
  return {
@@ -78,7 +66,16 @@ function createEntityCommands() {
78
66
 
79
67
  const entity = await moduleRepo.createEntity(entityData);
80
68
 
81
- return toEntityDto(entity);
69
+ return {
70
+ id: entity.id,
71
+ userId: entity.userId,
72
+ externalId: entity.externalId,
73
+ name: entity.name,
74
+ moduleName: entity.moduleName,
75
+ credentialId: entity.credential?._id
76
+ ? entity.credential._id.toString()
77
+ : entity.credential,
78
+ };
82
79
  } catch (error) {
83
80
  return mapErrorToResponse(error);
84
81
  }
@@ -112,7 +109,16 @@ function createEntityCommands() {
112
109
  return null;
113
110
  }
114
111
 
115
- return toEntityDto(entity);
112
+ return {
113
+ id: entity.id,
114
+ userId: entity.userId,
115
+ externalId: entity.externalId,
116
+ name: entity.name,
117
+ moduleName: entity.moduleName,
118
+ credentialId: entity.credential?._id
119
+ ? entity.credential._id.toString()
120
+ : entity.credential,
121
+ };
116
122
  } catch (error) {
117
123
  return mapErrorToResponse(error);
118
124
  }
@@ -133,7 +139,16 @@ function createEntityCommands() {
133
139
 
134
140
  const entities = await moduleRepo.findEntitiesByUserId(userId);
135
141
 
136
- return entities.map((entity) => toEntityDto(entity));
142
+ return entities.map((entity) => ({
143
+ id: entity.id,
144
+ userId: entity.userId,
145
+ externalId: entity.externalId,
146
+ name: entity.name,
147
+ moduleName: entity.moduleName,
148
+ credentialId: entity.credential?._id
149
+ ? entity.credential._id.toString()
150
+ : entity.credential,
151
+ }));
137
152
  } catch (error) {
138
153
  if (error.code) {
139
154
  return mapErrorToResponse(error);
@@ -165,7 +180,16 @@ function createEntityCommands() {
165
180
  moduleName
166
181
  );
167
182
 
168
- return entities.map((entity) => toEntityDto(entity));
183
+ return entities.map((entity) => ({
184
+ id: entity.id,
185
+ userId: entity.userId,
186
+ externalId: entity.externalId,
187
+ name: entity.name,
188
+ moduleName: entity.moduleName,
189
+ credentialId: entity.credential?._id
190
+ ? entity.credential._id.toString()
191
+ : entity.credential,
192
+ }));
169
193
  } catch (error) {
170
194
  if (error.code) {
171
195
  return mapErrorToResponse(error);
@@ -189,7 +213,16 @@ function createEntityCommands() {
189
213
 
190
214
  const entity = await moduleRepo.findEntityById(entityId);
191
215
 
192
- return toEntityDto(entity);
216
+ return {
217
+ id: entity.id,
218
+ userId: entity.userId,
219
+ externalId: entity.externalId,
220
+ name: entity.name,
221
+ moduleName: entity.moduleName,
222
+ credentialId: entity.credential?._id
223
+ ? entity.credential._id.toString()
224
+ : entity.credential,
225
+ };
193
226
  } catch (error) {
194
227
  return mapErrorToResponse(error);
195
228
  }
@@ -217,7 +250,16 @@ function createEntityCommands() {
217
250
  throw error;
218
251
  }
219
252
 
220
- return toEntityDto(entity);
253
+ return {
254
+ id: entity.id,
255
+ userId: entity.userId,
256
+ externalId: entity.externalId,
257
+ name: entity.name,
258
+ moduleName: entity.moduleName,
259
+ credentialId: entity.credential?._id
260
+ ? entity.credential._id.toString()
261
+ : entity.credential,
262
+ };
221
263
  } catch (error) {
222
264
  return mapErrorToResponse(error);
223
265
  }
@@ -29,12 +29,6 @@ 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');
38
32
  const {
39
33
  getModulesDefinitionFromIntegrationClasses,
40
34
  } = require('../../integrations/utils/map-integration-dto');
@@ -46,11 +40,6 @@ const ERROR_CODE_MAP = {
46
40
  EXTERNAL_ID_REQUIRED: 400,
47
41
  TYPE_REQUIRED: 400,
48
42
  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,
54
43
  };
55
44
 
56
45
  function mapErrorToResponse(error) {
@@ -174,17 +163,6 @@ function createIntegrationCommands({ integrationClass } = {}) {
174
163
  integrationRepository,
175
164
  });
176
165
 
177
- const addEntitiesToIntegrationUseCase = new AddEntitiesToIntegration({
178
- integrationRepository,
179
- moduleRepository,
180
- });
181
-
182
- const removeEntitiesFromIntegrationUseCase =
183
- new RemoveEntitiesFromIntegration({
184
- integrationRepository,
185
- moduleRepository,
186
- });
187
-
188
166
  return {
189
167
  findIntegrationById,
190
168
  listIntegrations,
@@ -329,47 +307,6 @@ function createIntegrationCommands({ integrationClass } = {}) {
329
307
  }
330
308
  },
331
309
 
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
-
373
310
  /**
374
311
  * Delete an integration by ID
375
312
  * @param {string} integrationId - Integration ID to delete
@@ -508,9 +508,9 @@ return entities.map((e) => ({
508
508
 
509
509
  **Verified:**
510
510
 
511
- - ✅ `postgres-relation-decryption.db.test.js` - Proves the bug exists
512
- - ✅ `postgres-decryption-fix-verification.db.test.js` - Verifies separate queries work
513
- - ✅ `mongo-decryption-fix-verification.db.test.js` - Verifies fix for MongoDB
511
+ - ✅ `postgres-relation-decryption.test.js` - Proves the bug exists
512
+ - ✅ `postgres-decryption-fix-verification.test.js` - Verifies separate queries work
513
+ - ✅ `mongo-decryption-fix-verification.test.js` - Verifies fix for MongoDB
514
514
 
515
515
  **Implementation Examples:**
516
516
 
@@ -174,46 +174,26 @@ function loadModuleEncryptionSchemas(integrations) {
174
174
  return;
175
175
  }
176
176
 
177
- const { getModulesDefinitionFromIntegrationClasses } = require('../../integrations/utils/map-integration-dto');
177
+ const { getModulesDefinitionFromIntegrationClasses } = require('../integrations/utils/map-integration-dto');
178
178
 
179
- // Integrations without modules contribute no credential fields.
180
- const integrationsWithModules = integrations.filter(
181
- (integration) => integration?.Definition?.modules
182
- );
183
- const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(
184
- integrationsWithModules
185
- );
186
-
187
- // Fields the core schema already encrypts (e.g. data.api_key) are
188
- // covered; registering them again would fail validation and drop every
189
- // other module field with them.
190
- const coreCredentialFields = CORE_ENCRYPTION_SCHEMA.Credential.fields;
191
- const credentialFields = extractCredentialFieldsFromModules(
192
- moduleDefinitions
193
- ).filter((field) => !coreCredentialFields.includes(field));
179
+ const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(integrations);
180
+ const credentialFields = extractCredentialFieldsFromModules(moduleDefinitions);
194
181
 
195
182
  if (credentialFields.length === 0) {
196
183
  return;
197
184
  }
198
185
 
199
- // Merge with whatever custom schema the app already registered rather
200
- // than replacing it (registerCustomSchema overwrites).
201
- const existingCredentialFields = customSchema.Credential?.fields || [];
202
- const mergedSchema = {
203
- ...customSchema,
186
+ const moduleSchema = {
204
187
  Credential: {
205
- ...customSchema.Credential,
206
- fields: [
207
- ...new Set([...existingCredentialFields, ...credentialFields]),
208
- ],
209
- },
188
+ fields: credentialFields
189
+ }
210
190
  };
211
191
 
212
192
  logger.info(
213
193
  `Registering module-level encryption for ${credentialFields.length} credential fields`
214
194
  );
215
195
 
216
- registerCustomSchema(mergedSchema);
196
+ registerCustomSchema(moduleSchema);
217
197
  }
218
198
 
219
199
  /**
@@ -1,6 +1,9 @@
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');
4
7
  const { resolveTelemetryConfig } = require('../telemetry/telemetry-config');
5
8
  const { registerDeniedKeys } = require('../logs/denied-keys');
6
9
  const {
@@ -33,7 +36,9 @@ function registerCredentialLogKeys(appDefinition, integrations) {
33
36
  * @function loadAppDefinition
34
37
  * @description Searches for the nearest backend package.json, loads the corresponding index.js file,
35
38
  * and extracts the application definition containing integrations and user configuration.
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.
39
+ * The defaults declared in the app-definition schema are applied first (ADR-051), so
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.
37
42
  * @throws {Error} Throws error if backend package.json cannot be found.
38
43
  * @throws {Error} Throws error if index.js file cannot be found in the backend directory.
39
44
  * @example
@@ -53,7 +58,9 @@ function loadAppDefinition() {
53
58
  }
54
59
 
55
60
  const backendJsFile = require(backendFilePath);
56
- const appDefinition = backendJsFile.Definition;
61
+ // The one place schema defaults are applied at runtime (ADR-051): a
62
+ // default in app-definition.schema.json is the value core sees.
63
+ const appDefinition = applyAppDefinitionDefaults(backendJsFile.Definition);
57
64
 
58
65
  const {
59
66
  integrations = [],
@@ -164,38 +164,6 @@ 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
-
199
167
  async findIntegrationByUserId(userId) {
200
168
  const objectId = toObjectId(userId);
201
169
  if (!objectId) return null;
@@ -184,36 +184,6 @@ 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
- }
217
187
  }
218
188
 
219
189
  module.exports = { IntegrationRepositoryInterface };
@@ -338,58 +338,6 @@ 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
-
393
341
  /**
394
342
  * Find integration by user ID (returns single integration)
395
343
  * Replaces: IntegrationModel.findOne({ user: userId }).populate('entities')
@@ -365,64 +365,6 @@ 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
-
426
368
  /**
427
369
  * Find integration by user ID (returns single integration)
428
370
  *
@@ -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._findSurvivor(
65
+ const survivor = await this._findDuplicate(
66
66
  userId,
67
67
  config?.type,
68
68
  entities
@@ -135,68 +135,30 @@ class CreateIntegration {
135
135
  return mapIntegrationClassToIntegrationDTO(integrationInstance);
136
136
  }
137
137
 
138
- async _findMatches(userId, type, entityIds) {
138
+ async _findDuplicate(userId, type, entityIds) {
139
139
  const candidates =
140
140
  await this.integrationRepository.findIntegrationsByUserId(userId);
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);
141
+ const target = [...(entityIds ?? [])].map(String).sort();
142
+
143
+ const matches = candidates.filter((integration) => {
144
+ // An IN_DELETION row is either mid-teardown or a zombie left
145
+ // behind by a failed deleteIntegrationById call — never a live
146
+ // integration. Treating it as a duplicate would hand a reinstall
147
+ // attempt a row nothing will ever move out of IN_DELETION,
148
+ // silently discarded by the queue worker forever. Let a fresh
149
+ // create proceed instead; the zombie is cleaned up out-of-band,
150
+ // matching how teardown failures are already handled here.
151
+ if (integration.status === 'IN_DELETION') return false;
152
+ if (integration.config?.type !== type) return false;
153
+ const current = [...(integration.entitiesIds ?? [])]
154
+ .map(String)
155
+ .sort();
161
156
  return (
162
- current.size === target.size &&
163
- [...target].every((id) => current.has(id))
157
+ current.length === target.length &&
158
+ current.every((id, index) => id === target[index])
164
159
  );
165
160
  });
166
161
 
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;
200
162
  return matches.length ? this._pickOldest(matches) : null;
201
163
  }
202
164
 
package/jest-setup.js CHANGED
@@ -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
- };
1
+ const {globalSetup} = require('@friggframework/test');
2
+ module.exports = globalSetup;
package/jest-teardown.js CHANGED
@@ -1,5 +1,2 @@
1
- const { restoreEnvironment } = require('@friggframework/test');
2
-
3
- module.exports = async function () {
4
- restoreEnvironment();
5
- };
1
+ const {globalTeardown} = require('@friggframework/test');
2
+ module.exports = globalTeardown;
package/jest.config.js CHANGED
@@ -20,8 +20,4 @@ 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$'],
27
23
  };
@@ -1,5 +1,3 @@
1
- const { toCredentialId } = require('./to-credential-id');
2
-
3
1
  /**
4
2
  * @param {import('../module').Module} moduleInstance
5
3
  * Convert a Module domain instance to a plain DTO suitable for JSON responses.
@@ -12,9 +10,9 @@ function mapModuleClassToModuleDTO(moduleInstance) {
12
10
  name: moduleInstance.name,
13
11
  userId: moduleInstance.userId,
14
12
  entity: moduleInstance.entity,
15
- credentialId: toCredentialId(moduleInstance.credential),
16
- type: moduleInstance.getName(),
13
+ credentialId: moduleInstance.credential?._id?.toString(),
14
+ type: moduleInstance.getName()
17
15
  };
18
16
  }
19
17
 
20
- module.exports = { mapModuleClassToModuleDTO };
18
+ module.exports = { mapModuleClassToModuleDTO };
package/package.json CHANGED
@@ -1,16 +1,18 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
+ "description": "",
3
4
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.710.91495fa.0",
5
+ "version": "2.0.0--canary.702.ef0dc8c.0",
5
6
  "dependencies": {
6
7
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
8
  "@aws-sdk/client-kms": "^3.588.0",
8
9
  "@aws-sdk/client-lambda": "^3.714.0",
9
10
  "@aws-sdk/client-s3": "^3.588.0",
10
- "@aws-sdk/client-scheduler": "^3.588.0",
11
+ "@aws-sdk/client-scheduler": "^3.1153.0",
11
12
  "@aws-sdk/client-sqs": "^3.588.0",
12
13
  "@aws-sdk/client-ssm": "^3.588.0",
13
14
  "@aws-sdk/s3-request-presigner": "^3.588.0",
15
+ "@friggframework/schemas": "2.0.0--canary.702.ef0dc8c.0",
14
16
  "@hapi/boom": "^10.0.1",
15
17
  "@opentelemetry/api": "^1.9.1",
16
18
  "@opentelemetry/context-async-hooks": "^2.9.0",
@@ -48,9 +50,9 @@
48
50
  }
49
51
  },
50
52
  "devDependencies": {
51
- "@friggframework/eslint-config": "2.0.0--canary.710.91495fa.0",
52
- "@friggframework/prettier-config": "2.0.0--canary.710.91495fa.0",
53
- "@friggframework/test": "2.0.0--canary.710.91495fa.0",
53
+ "@friggframework/eslint-config": "2.0.0--canary.702.ef0dc8c.0",
54
+ "@friggframework/prettier-config": "2.0.0--canary.702.ef0dc8c.0",
55
+ "@friggframework/test": "2.0.0--canary.702.ef0dc8c.0",
54
56
  "@prisma/client": "^6.19.3",
55
57
  "@types/lodash": "4.17.15",
56
58
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -68,7 +70,6 @@
68
70
  "scripts": {
69
71
  "lint:fix": "prettier --write --loglevel error . && eslint . --fix",
70
72
  "test": "jest --passWithNoTests # TODO",
71
- "test:db": "jest --config jest.db.config.js --runInBand",
72
73
  "prisma:generate:mongo": "npx prisma generate --schema ./prisma-mongodb/schema.prisma",
73
74
  "prisma:generate:postgres": "npx prisma generate --schema ./prisma-postgresql/schema.prisma",
74
75
  "prisma:generate": "npm run prisma:generate:mongo && npm run prisma:generate:postgres",
@@ -87,9 +88,8 @@
87
88
  "url": "https://github.com/friggframework/frigg/issues"
88
89
  },
89
90
  "homepage": "https://github.com/friggframework/frigg#readme",
90
- "description": "",
91
91
  "publishConfig": {
92
92
  "access": "public"
93
93
  },
94
- "gitHead": "91495fa7fd4cc9e0da6709172c245e97ee06daf4"
94
+ "gitHead": "ef0dc8cb6c6f56048a61b6759faad7322046925d"
95
95
  }
@@ -97,17 +97,16 @@ const QueuerUtil = {
97
97
  Id: uuid(),
98
98
  MessageBody: JSON.stringify(entry),
99
99
  });
100
- // Sends 10, then starts a fresh buffer. Each command gets its own
101
- // array: clearing the shared buffer in place would also empty the
102
- // Entries of a command that was already sent.
100
+ // Sends 10, then purges the buffer
103
101
  if (buffer.length === batchSize) {
104
- const batch = buffer.splice(0, buffer.length);
105
102
  const command = new SendMessageBatchCommand({
106
- Entries: batch,
103
+ Entries: buffer,
107
104
  QueueUrl: queueUrl,
108
105
  });
109
106
  const result = await sqs.send(command);
110
- inspectBatchResult(result, queueUrl, batch);
107
+ inspectBatchResult(result, queueUrl, buffer);
108
+ // Purge the buffer
109
+ buffer.splice(0, buffer.length);
111
110
  }
112
111
  }
113
112
 
@@ -23,7 +23,7 @@ class TestUserRepository {
23
23
 
24
24
  async createToken(userId, rawToken, minutes = 120) {
25
25
  const token = `token-for-${userId}-for-${minutes}-mins`;
26
- this.tokens.set(token, { id: token, userId, rawToken });
26
+ this.tokens.set(token, { user: userId, rawToken });
27
27
  return token;
28
28
  }
29
29
 
@@ -40,13 +40,8 @@ class GetUserFromBearerToken {
40
40
  throw Boom.unauthorized('Session Token Not Found');
41
41
  }
42
42
 
43
- const tokenUserId = sessionToken.userId;
44
- if (!tokenUserId) {
45
- throw Boom.unauthorized('Session Token Has No User');
46
- }
47
-
48
43
  if (this.userConfig.primary === 'organization') {
49
- const organizationUserData = await this.userRepository.findOrganizationUserById(tokenUserId);
44
+ const organizationUserData = await this.userRepository.findOrganizationUserById(sessionToken.user);
50
45
 
51
46
  if (!organizationUserData) {
52
47
  throw Boom.unauthorized('Organization User Not Found');
@@ -62,7 +57,7 @@ class GetUserFromBearerToken {
62
57
  );
63
58
  }
64
59
 
65
- const individualUserData = await this.userRepository.findIndividualUserById(tokenUserId);
60
+ const individualUserData = await this.userRepository.findIndividualUserById(sessionToken.user);
66
61
 
67
62
  if (!individualUserData) {
68
63
  throw Boom.unauthorized('Individual User Not Found');
@@ -1,92 +0,0 @@
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 };
@@ -1,70 +0,0 @@
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 };
@@ -1,42 +0,0 @@
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.db.config.js DELETED
@@ -1,23 +0,0 @@
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,14 +0,0 @@
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 };