@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.
Files changed (35) hide show
  1. package/application/commands/integration-commands.js +34 -1
  2. package/generated/prisma-mongodb/edge.js +6 -4
  3. package/generated/prisma-mongodb/index-browser.js +3 -1
  4. package/generated/prisma-mongodb/index.d.ts +3 -1
  5. package/generated/prisma-mongodb/index.js +6 -4
  6. package/generated/prisma-mongodb/package.json +1 -1
  7. package/generated/prisma-mongodb/schema.prisma +3 -1
  8. package/generated/prisma-mongodb/wasm.js +5 -3
  9. package/generated/prisma-postgresql/edge.js +6 -4
  10. package/generated/prisma-postgresql/index-browser.js +3 -1
  11. package/generated/prisma-postgresql/index.d.ts +3 -1
  12. package/generated/prisma-postgresql/index.js +6 -4
  13. package/generated/prisma-postgresql/package.json +1 -1
  14. package/generated/prisma-postgresql/schema.prisma +3 -1
  15. package/generated/prisma-postgresql/wasm.js +5 -3
  16. package/handlers/backend-utils.js +2 -2
  17. package/integrations/integration-base.js +150 -35
  18. package/integrations/integration-router.js +2 -1
  19. package/integrations/repositories/config-patch-shared.js +43 -0
  20. package/integrations/repositories/integration-repository-documentdb.js +54 -1
  21. package/integrations/repositories/integration-repository-interface.js +15 -0
  22. package/integrations/repositories/integration-repository-mongo.js +48 -0
  23. package/integrations/repositories/integration-repository-postgres.js +28 -0
  24. package/integrations/tests/doubles/dummy-integration-class.js +8 -0
  25. package/integrations/tests/doubles/test-integration-repository.js +24 -1
  26. package/integrations/use-cases/create-integration.js +138 -6
  27. package/integrations/use-cases/delete-integration-for-user.js +19 -1
  28. package/integrations/use-cases/patch-integration-config.js +39 -0
  29. package/integrations/use-cases/update-integration-config.js +32 -0
  30. package/package.json +5 -5
  31. package/prisma-mongodb/schema.prisma +3 -1
  32. package/prisma-postgresql/migrations/20260703000000_add_integration_status_in_creation_in_deletion/migration.sql +11 -0
  33. package/prisma-postgresql/migrations/20260703000001_integration_status_default_in_creation/migration.sql +8 -0
  34. package/prisma-postgresql/schema.prisma +3 -1
  35. 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 mapIntegrationClassToIntegrationDTO(integrationInstance);
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
- await integrationInstance.send('ON_DELETE');
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.98",
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.98",
42
- "@friggframework/prettier-config": "2.0.0-next.98",
43
- "@friggframework/test": "2.0.0-next.98",
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": "da2c1977216b9f939121b18d8461cc4e8a5b2b94"
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(ENABLED)
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(ENABLED)
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