@friggframework/core 2.0.0-next.115 → 2.0.0-next.117

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.
@@ -58,7 +58,10 @@ async function insertOne(client, collection, document) {
58
58
 
59
59
  // Provide helpful context for common errors
60
60
  if (error.code === 11000) {
61
- throw new Error(`${errorMsg} - Duplicate key violation`);
61
+ throw Object.assign(
62
+ new Error(`${errorMsg} - Duplicate key violation`),
63
+ { code: 11000 }
64
+ );
62
65
  }
63
66
  throw new Error(errorMsg);
64
67
  }
package/errors/index.js CHANGED
@@ -6,6 +6,9 @@ const {
6
6
  ParameterTypeError,
7
7
  } = require('./validation-errors');
8
8
  const { ClientSafeError } = require('./client-safe-error');
9
+ const {
10
+ MappingAlreadyExistsError,
11
+ } = require('./mapping-already-exists-error');
9
12
 
10
13
  module.exports = {
11
14
  BaseError,
@@ -14,4 +17,5 @@ module.exports = {
14
17
  RequiredPropertyError,
15
18
  ParameterTypeError,
16
19
  ClientSafeError,
20
+ MappingAlreadyExistsError,
17
21
  };
@@ -0,0 +1,14 @@
1
+ const { BaseError } = require('./base-error');
2
+
3
+ class MappingAlreadyExistsError extends BaseError {
4
+ constructor(integrationId, sourceId, ...errorOptions) {
5
+ super(
6
+ `Mapping already exists for integration ${integrationId} and sourceId ${sourceId}`,
7
+ ...errorOptions
8
+ );
9
+ this.integrationId = integrationId;
10
+ this.sourceId = sourceId;
11
+ }
12
+ }
13
+
14
+ module.exports = { MappingAlreadyExistsError };
@@ -483,6 +483,35 @@ async onWebhook({ data }) {
483
483
  }
484
484
  ```
485
485
 
486
+ `getMapping` followed by `upsertMapping` is not atomic. To claim an event so only one
487
+ concurrent worker proceeds, use the insert-only `createMapping`, which throws
488
+ `MappingAlreadyExistsError` (exported from `@friggframework/core`) when the
489
+ `sourceId` is already taken. `deleteMapping(sourceId)` releases the claim and is a
490
+ no-op when nothing exists.
491
+
492
+ ```javascript
493
+ const { MappingAlreadyExistsError } = require('@friggframework/core');
494
+
495
+ async onWebhook({ data }) {
496
+ const claimId = `claim:${data.body.id}`;
497
+ try {
498
+ await this.createMapping(claimId, { claimedAt: new Date() });
499
+ } catch (error) {
500
+ if (error instanceof MappingAlreadyExistsError) {
501
+ return { processed: false, duplicate: true };
502
+ }
503
+ throw error;
504
+ }
505
+
506
+ try {
507
+ await this.processEvent(data.body);
508
+ } catch (error) {
509
+ await this.deleteMapping(claimId);
510
+ throw error;
511
+ }
512
+ }
513
+ ```
514
+
486
515
  ### 4. Error Handling
487
516
  ```javascript
488
517
  async onWebhook({ data }) {
package/index.js CHANGED
@@ -57,6 +57,7 @@ const {
57
57
  HaltError,
58
58
  RequiredPropertyError,
59
59
  ParameterTypeError,
60
+ MappingAlreadyExistsError,
60
61
  } = require('./errors/index');
61
62
  const {
62
63
  IntegrationBase,
@@ -144,6 +145,7 @@ module.exports = {
144
145
  HaltError,
145
146
  RequiredPropertyError,
146
147
  ParameterTypeError,
148
+ MappingAlreadyExistsError,
147
149
 
148
150
  // integrations
149
151
  IntegrationBase,
@@ -457,6 +457,35 @@ class IntegrationBase {
457
457
  );
458
458
  }
459
459
 
460
+ /**
461
+ * Insert-only mapping write; usable as an atomic claim/lock row.
462
+ * @throws {MappingAlreadyExistsError} If a mapping for sourceId exists
463
+ */
464
+ async createMapping(sourceId, mapping) {
465
+ if (!sourceId) {
466
+ throw new Error(`sourceId must be set`);
467
+ }
468
+ return await this.integrationMappingRepository.createMapping(
469
+ this.id,
470
+ sourceId,
471
+ mapping
472
+ );
473
+ }
474
+
475
+ /**
476
+ * Deletes the mapping for sourceId. A missing mapping is not an error
477
+ * (resolves with deletedCount 0).
478
+ */
479
+ async deleteMapping(sourceId) {
480
+ if (!sourceId) {
481
+ throw new Error(`sourceId must be set`);
482
+ }
483
+ return await this.integrationMappingRepository.deleteMapping(
484
+ this.id,
485
+ sourceId
486
+ );
487
+ }
488
+
460
489
  /**
461
490
  * CHILDREN CAN OVERRIDE THESE CONFIGURATION METHODS
462
491
  */
@@ -1,4 +1,5 @@
1
1
  const { prisma } = require('../../database/prisma');
2
+ const { MappingAlreadyExistsError } = require('../../errors');
2
3
  const {
3
4
  toObjectId,
4
5
  fromObjectId,
@@ -180,6 +181,55 @@ class IntegrationMappingRepositoryDocumentDB extends IntegrationMappingRepositor
180
181
  return this._mapMapping(decryptedMapping);
181
182
  }
182
183
 
184
+ async createMapping(integrationId, sourceId, mapping) {
185
+ const now = new Date();
186
+ const encryptedDocument = await this.encryptionService.encryptFields(
187
+ 'IntegrationMapping',
188
+ {
189
+ integrationId,
190
+ sourceId:
191
+ sourceId === null || sourceId === undefined
192
+ ? null
193
+ : String(sourceId),
194
+ mapping,
195
+ createdAt: now,
196
+ updatedAt: now,
197
+ }
198
+ );
199
+
200
+ let insertedId;
201
+ try {
202
+ insertedId = await insertOne(
203
+ this.prisma,
204
+ 'IntegrationMapping',
205
+ encryptedDocument
206
+ );
207
+ } catch (error) {
208
+ if (error.code === 11000) {
209
+ throw new MappingAlreadyExistsError(integrationId, sourceId, {
210
+ cause: error,
211
+ });
212
+ }
213
+ throw error;
214
+ }
215
+
216
+ const created = await findOne(this.prisma, 'IntegrationMapping', {
217
+ _id: insertedId,
218
+ });
219
+ if (!created) {
220
+ throw new Error(
221
+ 'Failed to create mapping: Document not found after insert. ' +
222
+ 'This indicates a database consistency issue.'
223
+ );
224
+ }
225
+ return this._mapMapping(
226
+ await this.encryptionService.decryptFields(
227
+ 'IntegrationMapping',
228
+ created
229
+ )
230
+ );
231
+ }
232
+
183
233
  async findMappingsByIntegration(integrationId) {
184
234
  const filter = {};
185
235
  if (integrationId) filter.integrationId = integrationId;
@@ -39,6 +39,21 @@ class IntegrationMappingRepositoryInterface {
39
39
  throw new Error('Method upsertMapping must be implemented by subclass');
40
40
  }
41
41
 
42
+ /**
43
+ * Insert a mapping, failing if one already exists for the
44
+ * (integrationId, sourceId) pair. Atomic: usable as a claim/lock row.
45
+ *
46
+ * @param {string|number} integrationId - The integration ID
47
+ * @param {string} sourceId - The source ID for lookup
48
+ * @param {Object} mapping - The mapping data
49
+ * @returns {Promise<Object>} The created mapping document
50
+ * @throws {MappingAlreadyExistsError} If the pair already exists
51
+ * @abstract
52
+ */
53
+ async createMapping(integrationId, sourceId, mapping) {
54
+ throw new Error('createMapping must be implemented by subclass');
55
+ }
56
+
42
57
  /**
43
58
  * Find all mappings for an integration
44
59
  *
@@ -1,4 +1,5 @@
1
1
  const { prisma } = require('../../database/prisma');
2
+ const { MappingAlreadyExistsError } = require('../../errors');
2
3
  const {
3
4
  assertMappingWrittenUnencrypted,
4
5
  decryptQueriedMappings,
@@ -93,6 +94,34 @@ class IntegrationMappingRepositoryMongo extends IntegrationMappingRepositoryInte
93
94
  });
94
95
  }
95
96
 
97
+ /**
98
+ * Insert a mapping; fails if (integrationId, sourceId) already exists
99
+ *
100
+ * @param {string} integrationId - The integration ID
101
+ * @param {string} sourceId - The source ID
102
+ * @param {Object} mapping - The mapping data
103
+ * @returns {Promise<Object>} The created mapping document
104
+ * @throws {MappingAlreadyExistsError} If the pair already exists
105
+ */
106
+ async createMapping(integrationId, sourceId, mapping) {
107
+ try {
108
+ return await this.prisma.integrationMapping.create({
109
+ data: {
110
+ integrationId,
111
+ sourceId: this._toString(sourceId),
112
+ mapping,
113
+ },
114
+ });
115
+ } catch (error) {
116
+ if (error.code === 'P2002') {
117
+ throw new MappingAlreadyExistsError(integrationId, sourceId, {
118
+ cause: error,
119
+ });
120
+ }
121
+ throw error;
122
+ }
123
+ }
124
+
96
125
  /**
97
126
  * Find all mappings for an integration
98
127
  * Replaces: IntegrationMapping.find({ integration: integrationId })
@@ -1,4 +1,5 @@
1
1
  const { prisma } = require('../../database/prisma');
2
+ const { MappingAlreadyExistsError } = require('../../errors');
2
3
  const {
3
4
  assertMappingWrittenUnencrypted,
4
5
  decryptQueriedMappings,
@@ -150,6 +151,35 @@ class IntegrationMappingRepositoryPostgres extends IntegrationMappingRepositoryI
150
151
  return this._convertMappingIds(result);
151
152
  }
152
153
 
154
+ /**
155
+ * Insert a mapping; fails if (integrationId, sourceId) already exists
156
+ *
157
+ * @param {string} integrationId - The integration ID
158
+ * @param {string} sourceId - The source ID
159
+ * @param {Object} mapping - The mapping data
160
+ * @returns {Promise<Object>} The created mapping document
161
+ * @throws {MappingAlreadyExistsError} If the pair already exists
162
+ */
163
+ async createMapping(integrationId, sourceId, mapping) {
164
+ try {
165
+ const created = await this.prisma.integrationMapping.create({
166
+ data: {
167
+ integrationId: this._stringToInt(integrationId),
168
+ sourceId: this._toString(sourceId),
169
+ mapping,
170
+ },
171
+ });
172
+ return this._convertMappingIds(created);
173
+ } catch (error) {
174
+ if (error.code === 'P2002') {
175
+ throw new MappingAlreadyExistsError(integrationId, sourceId, {
176
+ cause: error,
177
+ });
178
+ }
179
+ throw error;
180
+ }
181
+ }
182
+
153
183
  /**
154
184
  * Find all mappings for an integration
155
185
  * Replaces: IntegrationMapping.find({ integration: integrationId })
@@ -1,4 +1,5 @@
1
1
  const { prisma } = require('../../database/prisma');
2
+ const { MappingAlreadyExistsError } = require('../../errors');
2
3
  const {
3
4
  IntegrationMappingRepositoryInterface,
4
5
  } = require('./integration-mapping-repository-interface');
@@ -69,6 +70,34 @@ class IntegrationMappingRepository extends IntegrationMappingRepositoryInterface
69
70
  });
70
71
  }
71
72
 
73
+ /**
74
+ * Insert a mapping; fails if (integrationId, sourceId) already exists
75
+ *
76
+ * @param {string} integrationId - The integration ID
77
+ * @param {string} sourceId - The source ID
78
+ * @param {Object} mapping - The mapping data
79
+ * @returns {Promise<Object>} The created mapping document
80
+ * @throws {MappingAlreadyExistsError} If the pair already exists
81
+ */
82
+ async createMapping(integrationId, sourceId, mapping) {
83
+ try {
84
+ return await this.prisma.integrationMapping.create({
85
+ data: {
86
+ integrationId,
87
+ sourceId,
88
+ mapping,
89
+ },
90
+ });
91
+ } catch (error) {
92
+ if (error.code === 'P2002') {
93
+ throw new MappingAlreadyExistsError(integrationId, sourceId, {
94
+ cause: error,
95
+ });
96
+ }
97
+ throw error;
98
+ }
99
+ }
100
+
72
101
  /**
73
102
  * Find all mappings for an integration
74
103
  * Replaces: IntegrationMapping.find({ integration: integrationId })
@@ -419,7 +419,7 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
419
419
  for (const [key, value] of Object.entries(patch)) {
420
420
  $set[`config.${key}`] = value;
421
421
  }
422
- $set.updatedAt = new Date();
422
+ $set.updatedAt = { $date: new Date().toISOString() };
423
423
 
424
424
  const result = await this.prisma.$runCommandRaw({
425
425
  findAndModify: 'Integration',
@@ -120,7 +120,7 @@ class ProcessRepositoryMongo extends ProcessRepositoryInterface {
120
120
  if (normalized.newState !== null) {
121
121
  $set.state = normalized.newState;
122
122
  }
123
- $set.updatedAt = new Date();
123
+ $set.updatedAt = { $date: new Date().toISOString() };
124
124
  update.$set = $set;
125
125
 
126
126
  if (Object.keys(normalized.pushSlice).length > 0) {
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.115",
4
+ "version": "2.0.0-next.117",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -47,9 +47,9 @@
47
47
  }
48
48
  },
49
49
  "devDependencies": {
50
- "@friggframework/eslint-config": "2.0.0-next.115",
51
- "@friggframework/prettier-config": "2.0.0-next.115",
52
- "@friggframework/test": "2.0.0-next.115",
50
+ "@friggframework/eslint-config": "2.0.0-next.117",
51
+ "@friggframework/prettier-config": "2.0.0-next.117",
52
+ "@friggframework/test": "2.0.0-next.117",
53
53
  "@prisma/client": "^6.19.3",
54
54
  "@types/lodash": "4.17.15",
55
55
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -89,5 +89,5 @@
89
89
  "publishConfig": {
90
90
  "access": "public"
91
91
  },
92
- "gitHead": "a90dfe5e48de5524e61f8919731b323afff5d852"
92
+ "gitHead": "27771927dd746cc517d0c09189ce63b6e859e8bc"
93
93
  }