@friggframework/core 2.0.0-next.98 → 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/use-cases/list-integrations-report.js +2 -0
|
@@ -22,23 +22,158 @@ class CreateIntegration {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* Executes the integration creation process.
|
|
25
|
+
* Executes the integration creation process. Reuses an existing
|
|
26
|
+
* integration when one already exists for the same userId, config.type,
|
|
27
|
+
* and entity set, instead of creating a duplicate.
|
|
26
28
|
* @async
|
|
27
29
|
* @param {string[]} entities - Array of entity IDs to associate with the integration.
|
|
28
30
|
* @param {string} userId - ID of the user creating the integration.
|
|
29
31
|
* @param {Object} config - Configuration object for the integration.
|
|
30
32
|
* @param {string} config.type - Type of integration to create.
|
|
31
|
-
* @returns {Promise<Object>} The created integration DTO.
|
|
33
|
+
* @returns {Promise<Object>} The created or reused integration DTO.
|
|
32
34
|
* @throws {Error} When integration class is not found for the specified type.
|
|
33
35
|
*/
|
|
34
36
|
async execute(entities, userId, config) {
|
|
37
|
+
console.log(
|
|
38
|
+
`[Frigg] Creating ${config?.type} integration for user ${userId} with entities [${entities}]`
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const existing = await this._findDuplicate(
|
|
42
|
+
userId,
|
|
43
|
+
config?.type,
|
|
44
|
+
entities
|
|
45
|
+
);
|
|
46
|
+
if (existing) {
|
|
47
|
+
console.log(
|
|
48
|
+
`[Frigg] Found existing integration ${existing.id} for the same user, type, and entities — reusing it`
|
|
49
|
+
);
|
|
50
|
+
return this._reuseExisting(existing, config);
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
const integrationRecord =
|
|
36
54
|
await this.integrationRepository.createIntegration(
|
|
37
55
|
entities,
|
|
38
56
|
userId,
|
|
39
57
|
config
|
|
40
58
|
);
|
|
59
|
+
console.log(
|
|
60
|
+
`[Frigg] Created integration ${integrationRecord.id} for user ${userId}`
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// A concurrent request may have created a matching row between the
|
|
64
|
+
// lookup above and this insert; re-check before any side effects run.
|
|
65
|
+
const survivor = await this._findDuplicate(
|
|
66
|
+
userId,
|
|
67
|
+
config?.type,
|
|
68
|
+
entities
|
|
69
|
+
);
|
|
70
|
+
if (survivor && String(survivor.id) !== String(integrationRecord.id)) {
|
|
71
|
+
console.log(
|
|
72
|
+
`[Frigg] Concurrent create detected — deleting duplicate ${integrationRecord.id} and reusing ${survivor.id}`
|
|
73
|
+
);
|
|
74
|
+
await this.integrationRepository.deleteIntegrationById(
|
|
75
|
+
integrationRecord.id
|
|
76
|
+
);
|
|
77
|
+
return this._reuseExisting(survivor, config);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const integrationInstance = await this._buildInstance(
|
|
81
|
+
integrationRecord
|
|
82
|
+
);
|
|
83
|
+
console.log(
|
|
84
|
+
`[Frigg] Sending ON_CREATE for integration ${integrationRecord.id}`
|
|
85
|
+
);
|
|
86
|
+
await integrationInstance.send('ON_CREATE', {
|
|
87
|
+
integrationId: integrationRecord.id,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return mapIntegrationClassToIntegrationDTO(integrationInstance);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async _reuseExisting(integrationRecord, config) {
|
|
94
|
+
// The caller may be reconnecting with changed settings. Persist the
|
|
95
|
+
// requested config onto the reused row as a shallow patch, so new
|
|
96
|
+
// values take effect while keys added during the original create
|
|
97
|
+
// (webhook ids, secrets) survive. `type` is re-written to the same
|
|
98
|
+
// value it dedupe-matched on, which is a harmless no-op.
|
|
99
|
+
if (config && Object.keys(config).length > 0) {
|
|
100
|
+
integrationRecord =
|
|
101
|
+
await this.integrationRepository.patchIntegrationConfig(
|
|
102
|
+
integrationRecord.id,
|
|
103
|
+
config
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const integrationInstance = await this._buildInstance(
|
|
108
|
+
integrationRecord
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
if (integrationInstance.status === 'IN_CREATION') {
|
|
112
|
+
// ON_CREATE never finished for this row (crashed mid-setup or is
|
|
113
|
+
// still running concurrently). Re-firing ON_CREATE here would
|
|
114
|
+
// risk re-registering a webhook a partially-completed attempt
|
|
115
|
+
// already created — unsafe without idempotent setup, which is a
|
|
116
|
+
// separate, larger change. Surfaced loudly rather than silently
|
|
117
|
+
// reused as if healthy, so it's diagnosable; testAuth below still
|
|
118
|
+
// runs and can at least surface bad credentials as ERROR.
|
|
119
|
+
console.warn(
|
|
120
|
+
`[Frigg] Integration ${integrationInstance.id} is still IN_CREATION on reuse — setup never completed`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// User is actively trying to reconnect; here if the integration is
|
|
125
|
+
// disabled, we can enable it again.
|
|
126
|
+
const authPassed = await integrationInstance.testAuth();
|
|
127
|
+
await integrationInstance.reconcileAuthStatus(authPassed);
|
|
128
|
+
if (integrationInstance.status === 'DISABLED') {
|
|
129
|
+
console.log(
|
|
130
|
+
`[Frigg] Integration ${integrationInstance.id} changed status from DISABLED to ENABLED`
|
|
131
|
+
);
|
|
132
|
+
await integrationInstance.persistStatus('ENABLED');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return mapIntegrationClassToIntegrationDTO(integrationInstance);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async _findDuplicate(userId, type, entityIds) {
|
|
139
|
+
const candidates =
|
|
140
|
+
await this.integrationRepository.findIntegrationsByUserId(userId);
|
|
141
|
+
const target = [...(entityIds ?? [])].map(String).sort();
|
|
41
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();
|
|
156
|
+
return (
|
|
157
|
+
current.length === target.length &&
|
|
158
|
+
current.every((id, index) => id === target[index])
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return matches.length ? this._pickOldest(matches) : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_pickOldest(records) {
|
|
166
|
+
return [...records].sort((a, b) => {
|
|
167
|
+
const diff =
|
|
168
|
+
new Date(a.createdAt ?? 0) - new Date(b.createdAt ?? 0);
|
|
169
|
+
if (diff !== 0) return diff;
|
|
170
|
+
return String(a.id).localeCompare(String(b.id), 'en', {
|
|
171
|
+
numeric: true,
|
|
172
|
+
});
|
|
173
|
+
})[0];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async _buildInstance(integrationRecord) {
|
|
42
177
|
const integrationClass = this.integrationClasses.find(
|
|
43
178
|
(integrationClass) =>
|
|
44
179
|
integrationClass.Definition.name ===
|
|
@@ -72,11 +207,8 @@ class CreateIntegration {
|
|
|
72
207
|
});
|
|
73
208
|
|
|
74
209
|
await integrationInstance.initialize();
|
|
75
|
-
await integrationInstance.send('ON_CREATE', {
|
|
76
|
-
integrationId: integrationRecord.id,
|
|
77
|
-
});
|
|
78
210
|
|
|
79
|
-
return
|
|
211
|
+
return integrationInstance;
|
|
80
212
|
}
|
|
81
213
|
}
|
|
82
214
|
|
|
@@ -92,7 +92,25 @@ class DeleteIntegrationForUser {
|
|
|
92
92
|
|
|
93
93
|
// Complete async initialization (load dynamic actions, register handlers)
|
|
94
94
|
await integrationInstance.initialize();
|
|
95
|
-
|
|
95
|
+
|
|
96
|
+
await integrationInstance.persistStatus('IN_DELETION');
|
|
97
|
+
try {
|
|
98
|
+
await integrationInstance.send('ON_DELETE');
|
|
99
|
+
} catch (error) {
|
|
100
|
+
const reason = error?.message ?? String(error);
|
|
101
|
+
console.error(
|
|
102
|
+
`[Integration Deletion] onDelete failed for integration ${integrationId}, leaving it IN_DELETION:`,
|
|
103
|
+
reason
|
|
104
|
+
);
|
|
105
|
+
await integrationInstance.updateIntegrationMessages.execute(
|
|
106
|
+
integrationId,
|
|
107
|
+
'errors',
|
|
108
|
+
'Integration Deletion Error',
|
|
109
|
+
`Deletion did not complete: ${reason}`,
|
|
110
|
+
Date.now()
|
|
111
|
+
);
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
96
114
|
|
|
97
115
|
await this.integrationRepository.deleteIntegrationById(integrationId);
|
|
98
116
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Use case for atomically merging a partial update into an integration's
|
|
3
|
+
* configuration, leaving keys not present in the patch untouched.
|
|
4
|
+
*
|
|
5
|
+
* The merge is shallow: only top-level config keys are affected, and each
|
|
6
|
+
* key in the patch replaces its existing value wholesale (a nested object is
|
|
7
|
+
* overwritten as a block, not deep-merged). To change one field inside a
|
|
8
|
+
* nested object without dropping its siblings, pass the whole updated object
|
|
9
|
+
* as that key's value. A `null` value sets the key to null (clears the field
|
|
10
|
+
* without removing it); to remove a key entirely, use a full replace via
|
|
11
|
+
* UpdateIntegrationConfig instead.
|
|
12
|
+
* @class PatchIntegrationConfig
|
|
13
|
+
*/
|
|
14
|
+
class PatchIntegrationConfig {
|
|
15
|
+
/**
|
|
16
|
+
* Creates a new PatchIntegrationConfig instance.
|
|
17
|
+
* @param {Object} params - Configuration parameters.
|
|
18
|
+
* @param {import('../repositories/integration-repository-interface').IntegrationRepositoryInterface} params.integrationRepository - Repository for integration data operations.
|
|
19
|
+
*/
|
|
20
|
+
constructor({ integrationRepository }) {
|
|
21
|
+
this.integrationRepository = integrationRepository;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Executes the config patch.
|
|
26
|
+
* @async
|
|
27
|
+
* @param {string} integrationId - ID of the integration to update.
|
|
28
|
+
* @param {Object} patch - Keys to merge into the existing config.
|
|
29
|
+
* @returns {Promise<Object>} The updated integration record.
|
|
30
|
+
*/
|
|
31
|
+
async execute(integrationId, patch) {
|
|
32
|
+
return this.integrationRepository.patchIntegrationConfig(
|
|
33
|
+
integrationId,
|
|
34
|
+
patch
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { PatchIntegrationConfig };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Use case for replacing an integration's entire configuration. Keys not
|
|
3
|
+
* present in the new config are deleted — use PatchIntegrationConfig to
|
|
4
|
+
* merge a partial update without losing untouched keys.
|
|
5
|
+
* @class UpdateIntegrationConfig
|
|
6
|
+
*/
|
|
7
|
+
class UpdateIntegrationConfig {
|
|
8
|
+
/**
|
|
9
|
+
* Creates a new UpdateIntegrationConfig instance.
|
|
10
|
+
* @param {Object} params - Configuration parameters.
|
|
11
|
+
* @param {import('../repositories/integration-repository-interface').IntegrationRepositoryInterface} params.integrationRepository - Repository for integration data operations.
|
|
12
|
+
*/
|
|
13
|
+
constructor({ integrationRepository }) {
|
|
14
|
+
this.integrationRepository = integrationRepository;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Executes the full config replace.
|
|
19
|
+
* @async
|
|
20
|
+
* @param {string} integrationId - ID of the integration to update.
|
|
21
|
+
* @param {Object} config - The new configuration object.
|
|
22
|
+
* @returns {Promise<Object>} The updated integration record.
|
|
23
|
+
*/
|
|
24
|
+
async execute(integrationId, config) {
|
|
25
|
+
return this.integrationRepository.updateIntegrationConfig(
|
|
26
|
+
integrationId,
|
|
27
|
+
config
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { UpdateIntegrationConfig };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/core",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0-next.
|
|
4
|
+
"version": "2.0.0-next.99",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
}
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@friggframework/eslint-config": "2.0.0-next.
|
|
42
|
-
"@friggframework/prettier-config": "2.0.0-next.
|
|
43
|
-
"@friggframework/test": "2.0.0-next.
|
|
41
|
+
"@friggframework/eslint-config": "2.0.0-next.99",
|
|
42
|
+
"@friggframework/prettier-config": "2.0.0-next.99",
|
|
43
|
+
"@friggframework/test": "2.0.0-next.99",
|
|
44
44
|
"@prisma/client": "^6.19.3",
|
|
45
45
|
"@types/lodash": "4.17.15",
|
|
46
46
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -80,5 +80,5 @@
|
|
|
80
80
|
"publishConfig": {
|
|
81
81
|
"access": "public"
|
|
82
82
|
},
|
|
83
|
-
"gitHead": "
|
|
83
|
+
"gitHead": "a1f801371a7b6a06fc04819bfe08408a5165b75d"
|
|
84
84
|
}
|
|
@@ -157,7 +157,7 @@ model Integration {
|
|
|
157
157
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
158
158
|
userId String? @db.ObjectId
|
|
159
159
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
160
|
-
status IntegrationStatus @default(
|
|
160
|
+
status IntegrationStatus @default(IN_CREATION)
|
|
161
161
|
|
|
162
162
|
// Configuration and version
|
|
163
163
|
config Json? // Integration configuration object
|
|
@@ -193,6 +193,8 @@ enum IntegrationStatus {
|
|
|
193
193
|
PROCESSING
|
|
194
194
|
DISABLED
|
|
195
195
|
ERROR
|
|
196
|
+
IN_CREATION
|
|
197
|
+
IN_DELETION
|
|
196
198
|
}
|
|
197
199
|
|
|
198
200
|
/// Integration-specific data mappings
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Add IN_CREATION and IN_DELETION to the IntegrationStatus enum.
|
|
2
|
+
-- Kept separate from the default change (next migration) because Postgres does
|
|
3
|
+
-- not allow a newly added enum value to be referenced in the same transaction
|
|
4
|
+
-- that adds it. Requires Postgres >= 12 (ADD VALUE ... IF NOT EXISTS).
|
|
5
|
+
--
|
|
6
|
+
-- Rollback note: once any row is written with status IN_CREATION or
|
|
7
|
+
-- IN_DELETION, rolling the app back to a version whose Prisma client predates
|
|
8
|
+
-- this migration will fail to read that row (unknown enum value). Backfill
|
|
9
|
+
-- any such rows to a known status (e.g. ENABLED/ERROR) before rolling back.
|
|
10
|
+
ALTER TYPE "IntegrationStatus" ADD VALUE IF NOT EXISTS 'IN_CREATION';
|
|
11
|
+
ALTER TYPE "IntegrationStatus" ADD VALUE IF NOT EXISTS 'IN_DELETION';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- New integrations are born IN_CREATION and transition to ENABLED / NEEDS_CONFIG
|
|
2
|
+
-- once onCreate completes. Metadata-only change; existing rows are untouched.
|
|
3
|
+
--
|
|
4
|
+
-- Deploy-order note: if this app runs migrations via a post-deploy step (e.g.
|
|
5
|
+
-- an explicit POST /db-migrate call) rather than pre-deploy, code that writes
|
|
6
|
+
-- IN_CREATION/IN_DELETION will error on the enum value until both migrations
|
|
7
|
+
-- in this pair have applied. Transient and retryable, not data-destructive.
|
|
8
|
+
ALTER TABLE "Integration" ALTER COLUMN "status" SET DEFAULT 'IN_CREATION';
|
|
@@ -150,7 +150,7 @@ model Integration {
|
|
|
150
150
|
id Int @id @default(autoincrement())
|
|
151
151
|
userId Int?
|
|
152
152
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
153
|
-
status IntegrationStatus @default(
|
|
153
|
+
status IntegrationStatus @default(IN_CREATION)
|
|
154
154
|
|
|
155
155
|
// Configuration and version
|
|
156
156
|
config Json? // Integration configuration object
|
|
@@ -184,6 +184,8 @@ enum IntegrationStatus {
|
|
|
184
184
|
PROCESSING
|
|
185
185
|
DISABLED
|
|
186
186
|
ERROR
|
|
187
|
+
IN_CREATION
|
|
188
|
+
IN_DELETION
|
|
187
189
|
}
|
|
188
190
|
|
|
189
191
|
/// Integration-specific data mappings
|
|
@@ -6,10 +6,12 @@ const SERVICE = 'frigg-core-api';
|
|
|
6
6
|
// Seeded so every known status appears (even at 0); unknown values added to
|
|
7
7
|
// the schema later are still counted dynamically.
|
|
8
8
|
const KNOWN_STATUSES = [
|
|
9
|
+
'IN_CREATION',
|
|
9
10
|
'ENABLED',
|
|
10
11
|
'ERROR',
|
|
11
12
|
'NEEDS_CONFIG',
|
|
12
13
|
'PROCESSING',
|
|
14
|
+
'IN_DELETION',
|
|
13
15
|
'DISABLED',
|
|
14
16
|
];
|
|
15
17
|
|