@friggframework/core 2.0.0-next.97 → 2.0.0-next.99
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/application/commands/integration-commands.js +34 -1
- package/generated/prisma-mongodb/edge.js +6 -4
- package/generated/prisma-mongodb/index-browser.js +3 -1
- package/generated/prisma-mongodb/index.d.ts +3 -1
- package/generated/prisma-mongodb/index.js +6 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +3 -1
- package/generated/prisma-mongodb/wasm.js +5 -3
- package/generated/prisma-postgresql/edge.js +6 -4
- package/generated/prisma-postgresql/index-browser.js +3 -1
- package/generated/prisma-postgresql/index.d.ts +3 -1
- package/generated/prisma-postgresql/index.js +6 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +3 -1
- package/generated/prisma-postgresql/wasm.js +5 -3
- package/handlers/backend-utils.js +2 -2
- package/integrations/integration-base.js +150 -35
- package/integrations/integration-router.js +2 -1
- package/integrations/repositories/config-patch-shared.js +43 -0
- package/integrations/repositories/integration-repository-documentdb.js +54 -1
- package/integrations/repositories/integration-repository-interface.js +15 -0
- package/integrations/repositories/integration-repository-mongo.js +48 -0
- package/integrations/repositories/integration-repository-postgres.js +28 -0
- package/integrations/tests/doubles/dummy-integration-class.js +8 -0
- package/integrations/tests/doubles/test-integration-repository.js +24 -1
- package/integrations/use-cases/create-integration.js +138 -6
- package/integrations/use-cases/delete-integration-for-user.js +19 -1
- package/integrations/use-cases/patch-integration-config.js +39 -0
- package/integrations/use-cases/update-integration-config.js +32 -0
- package/package.json +5 -5
- package/prisma-mongodb/schema.prisma +3 -1
- package/prisma-postgresql/migrations/20260703000000_add_integration_status_in_creation_in_deletion/migration.sql +11 -0
- package/prisma-postgresql/migrations/20260703000001_integration_status_default_in_creation/migration.sql +8 -0
- package/prisma-postgresql/schema.prisma +3 -1
- package/reporting/README.md +86 -0
- package/reporting/reporting-router.js +29 -0
- package/reporting/use-cases/list-integrations-report.js +6 -1
|
@@ -11,6 +11,12 @@ const {
|
|
|
11
11
|
const {
|
|
12
12
|
UpdateIntegrationMessages,
|
|
13
13
|
} = require('./use-cases/update-integration-messages');
|
|
14
|
+
const {
|
|
15
|
+
PatchIntegrationConfig,
|
|
16
|
+
} = require('./use-cases/patch-integration-config');
|
|
17
|
+
const {
|
|
18
|
+
UpdateIntegrationConfig,
|
|
19
|
+
} = require('./use-cases/update-integration-config');
|
|
14
20
|
const { validateExtensionBinding } = require('./extension');
|
|
15
21
|
|
|
16
22
|
const constantsToBeMigrated = {
|
|
@@ -43,6 +49,12 @@ class IntegrationBase {
|
|
|
43
49
|
updateIntegrationMessages = new UpdateIntegrationMessages({
|
|
44
50
|
integrationRepository: this.integrationRepository,
|
|
45
51
|
});
|
|
52
|
+
patchIntegrationConfig = new PatchIntegrationConfig({
|
|
53
|
+
integrationRepository: this.integrationRepository,
|
|
54
|
+
});
|
|
55
|
+
updateIntegrationConfig = new UpdateIntegrationConfig({
|
|
56
|
+
integrationRepository: this.integrationRepository,
|
|
57
|
+
});
|
|
46
58
|
|
|
47
59
|
static getOptionDetails() {
|
|
48
60
|
const options = new Options({
|
|
@@ -266,41 +278,57 @@ class IntegrationBase {
|
|
|
266
278
|
return modules;
|
|
267
279
|
}
|
|
268
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Check the current config against the required fields declared by
|
|
283
|
+
* `getConfigOptions()`. Config options use the react-jsonschema-form shape,
|
|
284
|
+
* so the required top-level keys live in `jsonSchema.required`. Records a
|
|
285
|
+
* warning for each missing field. Does not change integration status —
|
|
286
|
+
* the caller decides the consequence (see `onCreate`/`onUpdate`), the same
|
|
287
|
+
* separation `testAuth`/`reconcileAuthStatus` use for the auth axis.
|
|
288
|
+
* @returns {Promise<boolean>} True when a required field is missing.
|
|
289
|
+
*/
|
|
269
290
|
async validateConfig() {
|
|
270
|
-
const
|
|
271
|
-
const currentConfig = this.getConfig();
|
|
291
|
+
const { jsonSchema } = await this.getConfigOptions();
|
|
292
|
+
const currentConfig = this.getConfig() || {};
|
|
293
|
+
const requiredKeys = Array.isArray(jsonSchema?.required)
|
|
294
|
+
? jsonSchema.required
|
|
295
|
+
: [];
|
|
272
296
|
let needsConfig = false;
|
|
273
|
-
for (const
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
this.id,
|
|
285
|
-
'warnings',
|
|
286
|
-
'Config Validation Error',
|
|
287
|
-
`Missing required field of ${option.label}`,
|
|
288
|
-
Date.now()
|
|
289
|
-
);
|
|
290
|
-
}
|
|
297
|
+
for (const key of requiredKeys) {
|
|
298
|
+
if (!Object.prototype.hasOwnProperty.call(currentConfig, key)) {
|
|
299
|
+
needsConfig = true;
|
|
300
|
+
const label = jsonSchema?.properties?.[key]?.title || key;
|
|
301
|
+
await this.updateIntegrationMessages.execute(
|
|
302
|
+
this.id,
|
|
303
|
+
'warnings',
|
|
304
|
+
'Config Validation Error',
|
|
305
|
+
`Missing required field of ${label}`,
|
|
306
|
+
Date.now()
|
|
307
|
+
);
|
|
291
308
|
}
|
|
292
309
|
}
|
|
293
|
-
|
|
294
|
-
await this.updateIntegrationStatus.execute(this.id, 'NEEDS_CONFIG');
|
|
295
|
-
}
|
|
310
|
+
return needsConfig;
|
|
296
311
|
}
|
|
297
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Verify every module's credentials. Records a diagnostic error message
|
|
315
|
+
* per failing module and returns whether all passed. Does not directly
|
|
316
|
+
* change integration status — the caller decides the consequence (see
|
|
317
|
+
* reconcileAuthStatus), so a passive check and an active reconnect can
|
|
318
|
+
* react differently. (A module can still fire a credential-invalidated
|
|
319
|
+
* delegate that flips status via receiveNotification, independent of this
|
|
320
|
+
* return value.)
|
|
321
|
+
* @returns {Promise<boolean>} True when every module authenticated.
|
|
322
|
+
*/
|
|
298
323
|
async testAuth() {
|
|
299
324
|
let didAuthPass = true;
|
|
300
325
|
|
|
301
326
|
for (const module of Object.keys(this.constructor.Definition.modules)) {
|
|
302
327
|
try {
|
|
303
|
-
await this[module].testAuth();
|
|
328
|
+
const authPassed = await this[module].testAuth();
|
|
329
|
+
if (!authPassed) {
|
|
330
|
+
throw new Error(`testAuth returned false for module ${module}`);
|
|
331
|
+
}
|
|
304
332
|
} catch {
|
|
305
333
|
didAuthPass = false;
|
|
306
334
|
await this.updateIntegrationMessages.execute(
|
|
@@ -309,15 +337,35 @@ class IntegrationBase {
|
|
|
309
337
|
'Authentication Error',
|
|
310
338
|
`There was an error with your ${this[
|
|
311
339
|
module
|
|
312
|
-
].
|
|
340
|
+
].getName()} Entity.
|
|
313
341
|
Please reconnect/re-authenticate, or reach out to Support for assistance.`,
|
|
314
342
|
Date.now()
|
|
315
343
|
);
|
|
316
344
|
}
|
|
317
345
|
}
|
|
318
346
|
|
|
319
|
-
|
|
320
|
-
|
|
347
|
+
return didAuthPass;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Reconcile the auth-health axis (ERROR ↔ ENABLED) from a testAuth result.
|
|
352
|
+
* On success it never clears DISABLED — a user pause is not an auth-health
|
|
353
|
+
* state, so it is only lifted by a deliberate reconnect. On failure the
|
|
354
|
+
* integration is marked ERROR regardless of its prior status.
|
|
355
|
+
* @param {boolean} authPassed - The result of testAuth().
|
|
356
|
+
*/
|
|
357
|
+
async reconcileAuthStatus(authPassed) {
|
|
358
|
+
if (!authPassed) {
|
|
359
|
+
console.log(
|
|
360
|
+
`[Frigg] Integration ${this.id} failed to authenticate`
|
|
361
|
+
);
|
|
362
|
+
await this.persistStatus('ERROR');
|
|
363
|
+
}
|
|
364
|
+
if (authPassed && this.status === 'ERROR') {
|
|
365
|
+
console.log(
|
|
366
|
+
`[Frigg] auth confirmed for integration ${this.id} — clearing ERROR → ENABLED`
|
|
367
|
+
);
|
|
368
|
+
await this.persistStatus('ENABLED');
|
|
321
369
|
}
|
|
322
370
|
}
|
|
323
371
|
|
|
@@ -344,12 +392,39 @@ class IntegrationBase {
|
|
|
344
392
|
/**
|
|
345
393
|
* CHILDREN CAN OVERRIDE THESE CONFIGURATION METHODS
|
|
346
394
|
*/
|
|
347
|
-
|
|
348
|
-
|
|
395
|
+
/**
|
|
396
|
+
* Default post-create lifecycle hook. The integration is born IN_CREATION;
|
|
397
|
+
* moved to NEEDS_CONFIG when validateConfig finds a required field
|
|
398
|
+
* missing, otherwise enabled. If this hook throws, the integration is
|
|
399
|
+
* left IN_CREATION — a visibly incomplete create rather than a healthy
|
|
400
|
+
* looking one. Children can override to run their own setup (and then own
|
|
401
|
+
* their status transition, calling `super.onCreate()` to keep this default).
|
|
402
|
+
*/
|
|
403
|
+
async onCreate() {
|
|
404
|
+
const needsConfig = await this.validateConfig();
|
|
405
|
+
await this.persistStatus(needsConfig ? 'NEEDS_CONFIG' : 'ENABLED');
|
|
349
406
|
}
|
|
350
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Default post-update lifecycle hook: merges any submitted config in as a
|
|
410
|
+
* patch, then re-validates. A NEEDS_CONFIG integration moves to ENABLED
|
|
411
|
+
* once nothing required is missing — other statuses (DISABLED, ERROR,
|
|
412
|
+
* IN_CREATION, IN_DELETION) are left alone; a config edit shouldn't
|
|
413
|
+
* silently un-pause or auto-heal those. Children can override to run
|
|
414
|
+
* their own update logic.
|
|
415
|
+
* @param {Object} [params]
|
|
416
|
+
* @param {Object} [params.config] - Keys to merge into the existing config.
|
|
417
|
+
*/
|
|
351
418
|
async onUpdate(params) {
|
|
352
|
-
|
|
419
|
+
if (params?.config) {
|
|
420
|
+
await this.patchConfig(params.config);
|
|
421
|
+
}
|
|
422
|
+
const needsConfig = await this.validateConfig();
|
|
423
|
+
if (needsConfig) {
|
|
424
|
+
await this.persistStatus('NEEDS_CONFIG');
|
|
425
|
+
} else if (this.status === 'NEEDS_CONFIG') {
|
|
426
|
+
await this.persistStatus('ENABLED');
|
|
427
|
+
}
|
|
353
428
|
}
|
|
354
429
|
|
|
355
430
|
async onDelete(params) {}
|
|
@@ -500,6 +575,48 @@ class IntegrationBase {
|
|
|
500
575
|
this.messages.warnings.push(warning);
|
|
501
576
|
}
|
|
502
577
|
|
|
578
|
+
/**
|
|
579
|
+
* Persist a status change and keep the in-memory field in sync. The
|
|
580
|
+
* single place that couples both writes, so no caller can update the
|
|
581
|
+
* database while leaving `this.status` stale.
|
|
582
|
+
* @param {string} status - The new integration status.
|
|
583
|
+
*/
|
|
584
|
+
async persistStatus(status) {
|
|
585
|
+
await this.updateIntegrationStatus.execute(this.id, status);
|
|
586
|
+
this.status = status;
|
|
587
|
+
console.log(`[Frigg] Integration ${this.id} status changed to ${status}`);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Merge a partial update into config and keep the in-memory field in
|
|
592
|
+
* sync with what was actually persisted — not a local `{...this.config,
|
|
593
|
+
* ...patch}` guess, which would silently drop keys a concurrent writer
|
|
594
|
+
* already landed. Throws if the merge fails.
|
|
595
|
+
* @param {Object} patch - Keys to merge into the existing config.
|
|
596
|
+
*/
|
|
597
|
+
async patchConfig(patch) {
|
|
598
|
+
const updated = await this.patchIntegrationConfig.execute(
|
|
599
|
+
this.id,
|
|
600
|
+
patch
|
|
601
|
+
);
|
|
602
|
+
this.config = updated.config;
|
|
603
|
+
return this.config;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Replace config entirely and keep the in-memory field in sync. Keys
|
|
608
|
+
* omitted from the new config are deleted. Throws if the write fails.
|
|
609
|
+
* @param {Object} config - The new configuration object.
|
|
610
|
+
*/
|
|
611
|
+
async updateConfig(config) {
|
|
612
|
+
const updated = await this.updateIntegrationConfig.execute(
|
|
613
|
+
this.id,
|
|
614
|
+
config
|
|
615
|
+
);
|
|
616
|
+
this.config = updated.config;
|
|
617
|
+
return this.config;
|
|
618
|
+
}
|
|
619
|
+
|
|
503
620
|
isActive() {
|
|
504
621
|
return this.status === 'ENABLED' || this.status === 'ACTIVE';
|
|
505
622
|
}
|
|
@@ -655,7 +772,7 @@ class IntegrationBase {
|
|
|
655
772
|
* Receives notifications from modules (the Delegate pattern) when
|
|
656
773
|
* something integration-level needs attention. Today this catches the
|
|
657
774
|
* `CREDENTIAL_INVALIDATED` event Module fires from `markCredentialsInvalid`
|
|
658
|
-
* and flips this integration's status to
|
|
775
|
+
* and flips this integration's status to ERROR so the queue worker
|
|
659
776
|
* stops processing further webhooks until the user re-authorizes.
|
|
660
777
|
*
|
|
661
778
|
* Modules are wired to this delegate in `_appendModules()`, which runs
|
|
@@ -677,8 +794,7 @@ class IntegrationBase {
|
|
|
677
794
|
console.log(
|
|
678
795
|
`[Frigg] Module ${notifier?.name || '?'} reported invalid credentials for integration ${this.id} — marking ERROR`
|
|
679
796
|
);
|
|
680
|
-
await this.
|
|
681
|
-
this.status = 'ERROR';
|
|
797
|
+
await this.persistStatus('ERROR');
|
|
682
798
|
return;
|
|
683
799
|
}
|
|
684
800
|
|
|
@@ -687,8 +803,7 @@ class IntegrationBase {
|
|
|
687
803
|
console.log(
|
|
688
804
|
`[Frigg] Module ${notifier?.name || '?'} reported valid credentials for integration ${this.id} — clearing ERROR → ENABLED`
|
|
689
805
|
);
|
|
690
|
-
await this.
|
|
691
|
-
this.status = 'ENABLED';
|
|
806
|
+
await this.persistStatus('ENABLED');
|
|
692
807
|
}
|
|
693
808
|
}
|
|
694
809
|
}
|
|
@@ -468,7 +468,8 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
|
|
|
468
468
|
}
|
|
469
469
|
|
|
470
470
|
const start = Date.now();
|
|
471
|
-
await instance.testAuth();
|
|
471
|
+
const authPassed = await instance.testAuth();
|
|
472
|
+
await instance.reconcileAuthStatus(authPassed);
|
|
472
473
|
const errors = instance.record.messages?.errors?.filter(
|
|
473
474
|
({ timestamp }) => timestamp >= start
|
|
474
475
|
);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared validation for IntegrationRepository.patchIntegrationConfig().
|
|
3
|
+
*
|
|
4
|
+
* A patch is an atomic shallow merge into Integration.config: every key in
|
|
5
|
+
* the patch is written, every other existing key is left untouched. This
|
|
6
|
+
* contract is enforced here, before any adapter emits a query, so a bug fix
|
|
7
|
+
* (or a tighter rule) fixes all three backends in one place.
|
|
8
|
+
*
|
|
9
|
+
* Keys may not contain '.' or start with '$' — both are reserved by the
|
|
10
|
+
* MongoDB/DocumentDB update-command syntax the mongo and documentdb adapters
|
|
11
|
+
* use to address `config.<key>` paths. A value of `null` is allowed and sets
|
|
12
|
+
* the key to null — the only way to clear a config field over the PATCH HTTP
|
|
13
|
+
* endpoint (it sets the value, it does not remove the key). `undefined` is
|
|
14
|
+
* rejected: it can't arrive over JSON and signals a programming error, not an
|
|
15
|
+
* intent. Removing a key entirely still requires a full replace via updateConfig.
|
|
16
|
+
*/
|
|
17
|
+
function validateConfigPatch(patch) {
|
|
18
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
19
|
+
throw new Error('patchIntegrationConfig: patch must be a non-null object');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const keys = Object.keys(patch);
|
|
23
|
+
if (keys.length === 0) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
'patchIntegrationConfig: patch must contain at least one key'
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const key of keys) {
|
|
30
|
+
if (key.includes('.') || key.startsWith('$')) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`patchIntegrationConfig: patch key '${key}' cannot contain '.' or start with '$'`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
if (patch[key] === undefined) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`patchIntegrationConfig: patch['${key}'] cannot be undefined`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { validateConfigPatch };
|
|
@@ -12,6 +12,7 @@ const {
|
|
|
12
12
|
const {
|
|
13
13
|
IntegrationRepositoryInterface,
|
|
14
14
|
} = require('./integration-repository-interface');
|
|
15
|
+
const { validateConfigPatch } = require('./config-patch-shared');
|
|
15
16
|
|
|
16
17
|
class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
|
|
17
18
|
constructor() {
|
|
@@ -124,7 +125,7 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
|
|
|
124
125
|
userId: toObjectId(userId) || null,
|
|
125
126
|
config,
|
|
126
127
|
version: '0.0.0',
|
|
127
|
-
status: '
|
|
128
|
+
status: 'IN_CREATION',
|
|
128
129
|
entityIds: toObjectIdArray(entities),
|
|
129
130
|
messages: { errors: [], warnings: [], info: [], logs: [] },
|
|
130
131
|
errors: [],
|
|
@@ -190,6 +191,57 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
|
|
|
190
191
|
return this._mapIntegration(updated);
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Atomically merge a patch into the existing config with a per-key
|
|
196
|
+
* $set (config.<k> for each patch key), then re-read to shape the
|
|
197
|
+
* return value — DocumentDB's raw update command doesn't return the
|
|
198
|
+
* post-update document directly.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} integrationId - Integration ID
|
|
201
|
+
* @param {Object} patch - Keys to merge into the existing config
|
|
202
|
+
* @returns {Promise<Object>} Updated integration object
|
|
203
|
+
*/
|
|
204
|
+
async patchIntegrationConfig(integrationId, patch) {
|
|
205
|
+
validateConfigPatch(patch);
|
|
206
|
+
const objectId = toObjectId(integrationId);
|
|
207
|
+
if (!objectId) {
|
|
208
|
+
throw new Error(`Integration with id ${integrationId} not found`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const $set = { updatedAt: new Date() };
|
|
212
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
213
|
+
$set[`config.${key}`] = value;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const result = await updateOne(
|
|
217
|
+
this.prisma,
|
|
218
|
+
'Integration',
|
|
219
|
+
{ _id: objectId },
|
|
220
|
+
{ $set }
|
|
221
|
+
);
|
|
222
|
+
if (result.writeErrors?.length) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`Failed to patch integration config: ${result.writeErrors[0].errmsg}`
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (!result.n) {
|
|
228
|
+
throw new Error(`Integration with id ${integrationId} not found`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const updated = await findOne(this.prisma, 'Integration', { _id: objectId });
|
|
232
|
+
if (!updated) {
|
|
233
|
+
console.error('[IntegrationRepositoryDocumentDB] Integration not found after update', {
|
|
234
|
+
integrationId: fromObjectId(objectId),
|
|
235
|
+
patch,
|
|
236
|
+
});
|
|
237
|
+
throw new Error(
|
|
238
|
+
'Failed to update integration: Document not found after update. ' +
|
|
239
|
+
'This indicates a database consistency issue.'
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return this._mapIntegration(updated);
|
|
243
|
+
}
|
|
244
|
+
|
|
193
245
|
_mapIntegration(doc) {
|
|
194
246
|
const messages = this._extractMessages(doc);
|
|
195
247
|
return {
|
|
@@ -200,6 +252,7 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
|
|
|
200
252
|
version: doc?.version ?? null,
|
|
201
253
|
status: doc?.status ?? null,
|
|
202
254
|
messages,
|
|
255
|
+
createdAt: doc?.createdAt ?? null,
|
|
203
256
|
};
|
|
204
257
|
}
|
|
205
258
|
|
|
@@ -123,6 +123,21 @@ class IntegrationRepositoryInterface {
|
|
|
123
123
|
throw new Error('Method updateIntegrationConfig must be implemented by subclass');
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Atomically merge a partial update into an integration's config. Keys
|
|
128
|
+
* not present in the patch are left untouched; concurrent patches with
|
|
129
|
+
* disjoint keys must both persist. Patch values may not be null or
|
|
130
|
+
* undefined — key deletion is only supported via updateIntegrationConfig.
|
|
131
|
+
*
|
|
132
|
+
* @param {string|number} integrationId - Integration ID
|
|
133
|
+
* @param {Object} patch - Keys to merge into the existing config
|
|
134
|
+
* @returns {Promise<Object>} Updated integration object
|
|
135
|
+
* @abstract
|
|
136
|
+
*/
|
|
137
|
+
async patchIntegrationConfig(integrationId, patch) {
|
|
138
|
+
throw new Error('Method patchIntegrationConfig must be implemented by subclass');
|
|
139
|
+
}
|
|
140
|
+
|
|
126
141
|
/**
|
|
127
142
|
* Find all integrations whose entity set includes the given entity ID.
|
|
128
143
|
*
|
|
@@ -2,6 +2,7 @@ const { prisma } = require('../../database/prisma');
|
|
|
2
2
|
const {
|
|
3
3
|
IntegrationRepositoryInterface,
|
|
4
4
|
} = require('./integration-repository-interface');
|
|
5
|
+
const { validateConfigPatch } = require('./config-patch-shared');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* MongoDB Integration Repository Adapter
|
|
@@ -48,6 +49,7 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
|
|
|
48
49
|
version: integration.version,
|
|
49
50
|
status: integration.status,
|
|
50
51
|
messages: integration.messages,
|
|
52
|
+
createdAt: integration.createdAt,
|
|
51
53
|
}));
|
|
52
54
|
}
|
|
53
55
|
|
|
@@ -325,6 +327,52 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
|
|
|
325
327
|
messages: integration.messages,
|
|
326
328
|
};
|
|
327
329
|
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Atomically merge a patch into the existing config via findAndModify,
|
|
333
|
+
* so the write and the post-write read happen in one server-side round
|
|
334
|
+
* trip with no JS-side read-modify-write to race on. entityIds is a
|
|
335
|
+
* scalar array directly on the Integration document in Mongo, so the
|
|
336
|
+
* raw document already carries everything needed to shape the return
|
|
337
|
+
* value — no follow-up findUnique.
|
|
338
|
+
*
|
|
339
|
+
* @param {string} integrationId - Integration ID
|
|
340
|
+
* @param {Object} patch - Keys to merge into the existing config
|
|
341
|
+
* @returns {Promise<Object>} Updated integration object
|
|
342
|
+
*/
|
|
343
|
+
async patchIntegrationConfig(integrationId, patch) {
|
|
344
|
+
validateConfigPatch(patch);
|
|
345
|
+
|
|
346
|
+
const $set = {};
|
|
347
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
348
|
+
$set[`config.${key}`] = value;
|
|
349
|
+
}
|
|
350
|
+
$set.updatedAt = new Date();
|
|
351
|
+
|
|
352
|
+
const result = await this.prisma.$runCommandRaw({
|
|
353
|
+
findAndModify: 'Integration',
|
|
354
|
+
query: { _id: { $oid: integrationId } },
|
|
355
|
+
update: { $set },
|
|
356
|
+
new: true,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const doc = result && result.value;
|
|
360
|
+
if (!doc) {
|
|
361
|
+
throw new Error(`Integration with id ${integrationId} not found`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
id: doc._id.$oid ?? doc._id,
|
|
366
|
+
entitiesIds: (doc.entityIds || []).map(
|
|
367
|
+
(entityId) => entityId.$oid ?? entityId
|
|
368
|
+
),
|
|
369
|
+
userId: doc.userId?.$oid ?? doc.userId ?? null,
|
|
370
|
+
config: doc.config,
|
|
371
|
+
version: doc.version,
|
|
372
|
+
status: doc.status,
|
|
373
|
+
messages: doc.messages,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
328
376
|
}
|
|
329
377
|
|
|
330
378
|
module.exports = { IntegrationRepositoryMongo };
|
|
@@ -2,6 +2,7 @@ const { prisma } = require('../../database/prisma');
|
|
|
2
2
|
const {
|
|
3
3
|
IntegrationRepositoryInterface,
|
|
4
4
|
} = require('./integration-repository-interface');
|
|
5
|
+
const { validateConfigPatch } = require('./config-patch-shared');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* PostgreSQL Integration Repository Adapter
|
|
@@ -84,6 +85,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
|
|
|
84
85
|
version: converted.version,
|
|
85
86
|
status: converted.status,
|
|
86
87
|
messages: converted.messages,
|
|
88
|
+
createdAt: converted.createdAt,
|
|
87
89
|
};
|
|
88
90
|
});
|
|
89
91
|
}
|
|
@@ -348,6 +350,32 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
|
|
|
348
350
|
};
|
|
349
351
|
}
|
|
350
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Atomically merge a patch into the existing config with a single
|
|
355
|
+
* jsonb concatenation — the database does the merge, so there is no
|
|
356
|
+
* JS-side read-modify-write for concurrent callers to race on.
|
|
357
|
+
*
|
|
358
|
+
* @param {string} integrationId - Integration ID (string from application layer)
|
|
359
|
+
* @param {Object} patch - Keys to merge into the existing config
|
|
360
|
+
* @returns {Promise<Object>} Updated integration object with string IDs
|
|
361
|
+
*/
|
|
362
|
+
async patchIntegrationConfig(integrationId, patch) {
|
|
363
|
+
validateConfigPatch(patch);
|
|
364
|
+
|
|
365
|
+
const intId = this._convertId(integrationId);
|
|
366
|
+
const affectedCount = await this.prisma.$executeRawUnsafe(
|
|
367
|
+
'UPDATE "Integration" SET "config" = COALESCE("config", \'{}\'::jsonb) || $1::jsonb, "updatedAt" = NOW() WHERE "id" = $2',
|
|
368
|
+
JSON.stringify(patch),
|
|
369
|
+
intId
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
if (!affectedCount) {
|
|
373
|
+
throw new Error(`Integration with id ${integrationId} not found`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return this.findIntegrationById(integrationId);
|
|
377
|
+
}
|
|
378
|
+
|
|
351
379
|
/**
|
|
352
380
|
* Find all integrations whose entity set includes the given entity ID.
|
|
353
381
|
*
|
|
@@ -32,6 +32,7 @@ class DummyIntegration extends IntegrationBase {
|
|
|
32
32
|
constructor(params) {
|
|
33
33
|
super(params);
|
|
34
34
|
this.sendSpy = jest.fn();
|
|
35
|
+
this.testAuthSpy = jest.fn();
|
|
35
36
|
this.eventCallHistory = [];
|
|
36
37
|
this.events = {};
|
|
37
38
|
|
|
@@ -59,6 +60,9 @@ class DummyIntegration extends IntegrationBase {
|
|
|
59
60
|
if (event === 'ON_UPDATE') {
|
|
60
61
|
await this.onUpdate(data);
|
|
61
62
|
}
|
|
63
|
+
if (event === 'ON_DELETE') {
|
|
64
|
+
await this.onDelete(data);
|
|
65
|
+
}
|
|
62
66
|
return { event, data };
|
|
63
67
|
}
|
|
64
68
|
|
|
@@ -66,6 +70,10 @@ class DummyIntegration extends IntegrationBase {
|
|
|
66
70
|
return;
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
async testAuth() {
|
|
74
|
+
this.testAuthSpy();
|
|
75
|
+
}
|
|
76
|
+
|
|
69
77
|
async onCreate({ integrationId }) {
|
|
70
78
|
return;
|
|
71
79
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { v4: uuid } = require('uuid');
|
|
2
|
+
const { validateConfigPatch } = require('../../repositories/config-patch-shared');
|
|
2
3
|
|
|
3
4
|
class TestIntegrationRepository {
|
|
4
5
|
constructor() {
|
|
@@ -15,8 +16,9 @@ class TestIntegrationRepository {
|
|
|
15
16
|
userId: userId,
|
|
16
17
|
config,
|
|
17
18
|
version: '0.0.0',
|
|
18
|
-
status: '
|
|
19
|
+
status: 'IN_CREATION',
|
|
19
20
|
messages: {},
|
|
21
|
+
createdAt: new Date(),
|
|
20
22
|
};
|
|
21
23
|
this.store.set(id, record);
|
|
22
24
|
this.operationHistory.push({ operation: 'create', id, userId, config });
|
|
@@ -72,6 +74,9 @@ class TestIntegrationRepository {
|
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
async updateIntegrationConfig(id, config) {
|
|
77
|
+
if (config === null || config === undefined) {
|
|
78
|
+
throw new Error('Config parameter is required');
|
|
79
|
+
}
|
|
75
80
|
const rec = this.store.get(id);
|
|
76
81
|
if (!rec) {
|
|
77
82
|
this.operationHistory.push({ operation: 'updateConfig', id, success: false });
|
|
@@ -82,6 +87,24 @@ class TestIntegrationRepository {
|
|
|
82
87
|
return rec;
|
|
83
88
|
}
|
|
84
89
|
|
|
90
|
+
async patchIntegrationConfig(id, patch) {
|
|
91
|
+
validateConfigPatch(patch);
|
|
92
|
+
if (!this.store.has(id)) {
|
|
93
|
+
this.operationHistory.push({ operation: 'patchConfig', id, success: false });
|
|
94
|
+
throw new Error(`Integration with id ${id} not found`);
|
|
95
|
+
}
|
|
96
|
+
// Simulates the DB round trip real adapters make: the merge reads
|
|
97
|
+
// the record fresh at write time rather than off a snapshot taken
|
|
98
|
+
// before the await, so concurrent patches to the same id can't lose
|
|
99
|
+
// each other's keys — same guarantee as `config || $1::jsonb` in
|
|
100
|
+
// postgres or a mongo/documentdb findAndModify.
|
|
101
|
+
await Promise.resolve();
|
|
102
|
+
const rec = this.store.get(id);
|
|
103
|
+
rec.config = { ...rec.config, ...patch };
|
|
104
|
+
this.operationHistory.push({ operation: 'patchConfig', id, success: true });
|
|
105
|
+
return rec;
|
|
106
|
+
}
|
|
107
|
+
|
|
85
108
|
async deleteIntegrationById(id) {
|
|
86
109
|
const existed = this.store.has(id);
|
|
87
110
|
const result = this.store.delete(id);
|