@friggframework/core 2.0.0--canary.5ff5209.0 → 2.0.0--canary.590.094a71b.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.
@@ -28,8 +28,9 @@
28
28
  * const updateMetrics = new UpdateProcessMetrics({ processRepository, websocketService });
29
29
  * await updateMetrics.execute(processId, {
30
30
  * processed: 100,
31
- * success: 95,
31
+ * success: 92,
32
32
  * errors: 5,
33
+ * skipped: 3,
33
34
  * errorDetails: [{ contactId: 'abc', error: 'Missing email', timestamp: '...' }]
34
35
  * });
35
36
  */
@@ -54,6 +55,11 @@ class UpdateProcessMetrics {
54
55
  * @param {number} [metricsUpdate.processed=0] - Records processed in this batch
55
56
  * @param {number} [metricsUpdate.success=0] - Successful records
56
57
  * @param {number} [metricsUpdate.errors=0] - Failed records
58
+ * @param {number} [metricsUpdate.skipped=0] - Intentionally-skipped
59
+ * records (hash-match, dedupe, loop protection, etc.). Increments
60
+ * `results.aggregateData.totalSkipped`. Distinct from errors so the
61
+ * UI can show `processed = synced + failed + skipped` without
62
+ * conflating intentional skips with failures.
57
63
  * @param {Array} [metricsUpdate.errorDetails=[]] - Error details array
58
64
  * @returns {Promise<Object>} Updated process record
59
65
  * @throws {Error} If process not found or update fails
@@ -71,9 +77,11 @@ class UpdateProcessMetrics {
71
77
  const processed = metricsUpdate.processed || 0;
72
78
  const success = metricsUpdate.success || 0;
73
79
  const errors = metricsUpdate.errors || 0;
80
+ const skipped = metricsUpdate.skipped || 0;
74
81
  if (processed) increment['context.processedRecords'] = processed;
75
82
  if (success) increment['results.aggregateData.totalSynced'] = success;
76
83
  if (errors) increment['results.aggregateData.totalFailed'] = errors;
84
+ if (skipped) increment['results.aggregateData.totalSkipped'] = skipped;
77
85
 
78
86
  const pushSlice = {};
79
87
  if (
@@ -191,6 +199,7 @@ class UpdateProcessMetrics {
191
199
  total: context.totalRecords || 0,
192
200
  successCount: aggregateData.totalSynced || 0,
193
201
  errorCount: aggregateData.totalFailed || 0,
202
+ skippedCount: aggregateData.totalSkipped || 0,
194
203
  recordsPerSecond: aggregateData.recordsPerSecond || 0,
195
204
  estimatedCompletion: context.estimatedCompletion || null,
196
205
  timestamp: new Date().toISOString(),
package/modules/module.js CHANGED
@@ -41,6 +41,8 @@ class Module extends Delegate {
41
41
  // Module → parent delegate (typically IntegrationBase) events
42
42
  this.DLGT_CREDENTIAL_INVALIDATED = 'CREDENTIAL_INVALIDATED';
43
43
  this.delegateTypes.push(this.DLGT_CREDENTIAL_INVALIDATED);
44
+ this.DLGT_CREDENTIAL_VALIDATED = 'CREDENTIAL_VALIDATED';
45
+ this.delegateTypes.push(this.DLGT_CREDENTIAL_VALIDATED);
44
46
 
45
47
  Object.assign(this, this.definition.requiredAuthMethods);
46
48
 
@@ -123,6 +125,20 @@ class Module extends Delegate {
123
125
  credentialDetails
124
126
  );
125
127
  this.credential = persisted;
128
+
129
+ if (this.credential?.id) {
130
+ try {
131
+ await this.notify(this.DLGT_CREDENTIAL_VALIDATED, {
132
+ credentialId: this.credential.id,
133
+ moduleName: this.name,
134
+ });
135
+ } catch (err) {
136
+ console.error(
137
+ `[Frigg] Failed to propagate CREDENTIAL_VALIDATED for module ${this.name}:`,
138
+ err?.message || err
139
+ );
140
+ }
141
+ }
126
142
  }
127
143
 
128
144
  async receiveNotification(notifier, delegateString, object = null) {
@@ -151,9 +151,9 @@ class Requester extends Delegate {
151
151
  await new Promise((resolve) => setTimeout(resolve, delay));
152
152
  return this._request(url, options, i + 1);
153
153
  } else if (status === 401) {
154
- if (!this.isRefreshable || this.refreshCount > 0) {
154
+ if (!this.isRefreshable) {
155
155
  await this.notify(this.DLGT_INVALID_AUTH);
156
- } else {
156
+ } else if (this.refreshCount === 0) {
157
157
  this.refreshCount++;
158
158
  const refreshSucceeded = await this.refreshAuth();
159
159
  if (refreshSucceeded) {
@@ -178,6 +178,11 @@ class Requester extends Delegate {
178
178
  );
179
179
  }
180
180
 
181
+ // Successful response: reset the per-instance refresh budget so
182
+ // a later 401 in the same Requester lifetime can attempt refresh
183
+ // again instead of silently falling through.
184
+ this.refreshCount = 0;
185
+
181
186
  // parsedBody consumes the response body stream. If the server
182
187
  // stalls mid-stream the timer (still armed) aborts it.
183
188
  return options.returnFullRes
@@ -28,6 +28,11 @@ class ProcessAuthorizationCallback {
28
28
  }
29
29
 
30
30
  async execute(userId, entityType, params) {
31
+ const hasCode = Boolean(params && params.code);
32
+ console.log(
33
+ `[Frigg] processAuthorizationCallback start userId=${userId} entityType=${entityType} hasCode=${hasCode}`
34
+ );
35
+
31
36
  const moduleDefinition = this.moduleDefinitions.find((def) => {
32
37
  return entityType === def.moduleName;
33
38
  });
@@ -38,12 +43,29 @@ class ProcessAuthorizationCallback {
38
43
  );
39
44
  }
40
45
 
41
- // todo: check if we need to pass entity to Module, right now it's null
42
- let entity = null;
46
+ // Bootstrap the Module with the existing entity (if any) so the API
47
+ // requester is preloaded with prior tokens. This enables a refresh
48
+ // fallback when callers lack a fresh OAuth code, and lets us match a
49
+ // re-auth back to the existing credential record by id.
50
+ const existingEntities =
51
+ await this.moduleRepository.findEntitiesByUserIdAndModuleName(
52
+ userId,
53
+ entityType
54
+ );
55
+ const existingEntity =
56
+ existingEntities && existingEntities.length > 0
57
+ ? existingEntities[0]
58
+ : null;
59
+
60
+ if (existingEntity) {
61
+ console.log(
62
+ `[Frigg] processAuthorizationCallback found existing entity id=${existingEntity.id} credentialId=${existingEntity.credential?.id}`
63
+ );
64
+ }
43
65
 
44
66
  const module = new Module({
45
67
  userId,
46
- entity,
68
+ entity: existingEntity,
47
69
  definition: moduleDefinition,
48
70
  });
49
71
 
@@ -53,6 +75,16 @@ class ProcessAuthorizationCallback {
53
75
  module.api,
54
76
  params
55
77
  );
78
+ console.log(
79
+ `[Frigg] processAuthorizationCallback OAuth getToken complete userId=${userId} entityType=${entityType}`
80
+ );
81
+ // Belt-and-suspenders: persist tokens explicitly here rather than
82
+ // relying solely on the DLGT_TOKEN_UPDATE notification chain
83
+ // inside setTokens. The notification path remains in place but
84
+ // has been observed to no-op silently in some prod paths,
85
+ // leaving newly-issued tokens unsaved while the user-visible
86
+ // OAuth flow appears to succeed.
87
+ await this.onTokenUpdate(module, moduleDefinition, userId);
56
88
  } else {
57
89
  tokenResponse =
58
90
  await moduleDefinition.requiredAuthMethods.setAuthParams(
@@ -62,6 +94,10 @@ class ProcessAuthorizationCallback {
62
94
  await this.onTokenUpdate(module, moduleDefinition, userId);
63
95
  }
64
96
 
97
+ console.log(
98
+ `[Frigg] processAuthorizationCallback credential persisted credentialId=${module.credential?.id} authIsValid=${module.credential?.authIsValid}`
99
+ );
100
+
65
101
  const authRes = await module.testAuth();
66
102
  if (!authRes) {
67
103
  throw new Error('Authorization failed');
@@ -90,7 +126,12 @@ class ProcessAuthorizationCallback {
90
126
  // credential + entity are already persisted. Operators can recover
91
127
  // stuck integrations manually.
92
128
  try {
93
- await this.restoreIntegrationsForEntity(persistedEntity.id);
129
+ const restoredCount = await this.restoreIntegrationsForEntity(
130
+ persistedEntity.id
131
+ );
132
+ console.log(
133
+ `[Frigg] processAuthorizationCallback restored ${restoredCount} integration(s) for entityId=${persistedEntity.id}`
134
+ );
94
135
  } catch (err) {
95
136
  console.error(
96
137
  `[Frigg] Failed to restore integrations for entity ${persistedEntity.id} after successful re-auth — manual intervention may be needed`,
@@ -106,11 +147,12 @@ class ProcessAuthorizationCallback {
106
147
  }
107
148
 
108
149
  async restoreIntegrationsForEntity(entityId) {
109
- if (!this.integrationRepository) return;
150
+ if (!this.integrationRepository) return 0;
110
151
  const integrations =
111
152
  await this.integrationRepository.findIntegrationsByEntityId(
112
153
  entityId
113
154
  );
155
+ let restored = 0;
114
156
  for (const integration of integrations) {
115
157
  if (STATUSES_RESET_ON_REAUTH.includes(integration.status)) {
116
158
  console.log(
@@ -120,8 +162,10 @@ class ProcessAuthorizationCallback {
120
162
  integration.id,
121
163
  'ENABLED'
122
164
  );
165
+ restored++;
123
166
  }
124
167
  }
168
+ return restored;
125
169
  }
126
170
 
127
171
  async onTokenUpdate(module, moduleDefinition, userId) {
@@ -162,6 +206,28 @@ class ProcessAuthorizationCallback {
162
206
  });
163
207
 
164
208
  if (existingEntity) {
209
+ // Repoint the entity's credentialId when re-auth produced a
210
+ // different credential than the one currently linked. This
211
+ // happens when the user re-authenticates against a different
212
+ // workspace/account of the same provider — `upsertCredential`
213
+ // matches/creates a credential keyed by externalId, but
214
+ // findEntity matches the entity by its own externalId, leaving
215
+ // the entity still linked to the prior workspace's credential
216
+ // unless we explicitly update the link.
217
+ const existingCredentialId = existingEntity.credential?.id;
218
+ if (
219
+ credentialId &&
220
+ String(existingCredentialId) !== String(credentialId)
221
+ ) {
222
+ console.log(
223
+ `[Frigg] Repointing entity ${existingEntity.id} credentialId ${existingCredentialId} -> ${credentialId} after re-auth`
224
+ );
225
+ const updated = await this.moduleRepository.updateEntity(
226
+ existingEntity.id,
227
+ { credential: credentialId }
228
+ );
229
+ if (updated) return updated;
230
+ }
165
231
  return existingEntity;
166
232
  }
167
233
 
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--canary.5ff5209.0",
4
+ "version": "2.0.0--canary.590.094a71b.0",
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--canary.5ff5209.0",
42
- "@friggframework/prettier-config": "2.0.0--canary.5ff5209.0",
43
- "@friggframework/test": "2.0.0--canary.5ff5209.0",
41
+ "@friggframework/eslint-config": "2.0.0--canary.590.094a71b.0",
42
+ "@friggframework/prettier-config": "2.0.0--canary.590.094a71b.0",
43
+ "@friggframework/test": "2.0.0--canary.590.094a71b.0",
44
44
  "@prisma/client": "^6.17.0",
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": "5ff5209460d3a908810fe53b5ff07eacccde963e"
83
+ "gitHead": "094a71baaac437d208e2ad749bc3c79a9b0c15bb"
84
84
  }
@@ -18,13 +18,88 @@ const awsConfigOptions = () => {
18
18
 
19
19
  const sqs = new SQSClient(awsConfigOptions());
20
20
 
21
+ // Best-effort extraction of the logical event/processId/integrationId from a
22
+ // JSON message body. Used only for log correlation — never throws.
23
+ const summarizeMessageBody = (bodyStr) => {
24
+ try {
25
+ const parsed = JSON.parse(bodyStr);
26
+ return {
27
+ event: parsed?.event,
28
+ processId: parsed?.data?.processId,
29
+ integrationId: parsed?.data?.integrationId,
30
+ };
31
+ } catch {
32
+ return {};
33
+ }
34
+ };
35
+
36
+ // Inspect SendMessageBatchResult for partial failures and log them.
37
+ // AWS SendMessageBatch can succeed at the HTTP level while individual entries
38
+ // are rejected (KMS errors, per-entry throttling, service errors). Callers that
39
+ // don't inspect result.Failed silently lose those messages. This logs the
40
+ // details — including the logical event/processId of the failed entry — so
41
+ // the loss is visible and correlatable in CloudWatch.
42
+ const inspectBatchResult = (result, queueUrl, buffer) => {
43
+ const bufferSize = buffer.length;
44
+ const failedCount = result?.Failed?.length ?? 0;
45
+ const successCount = result?.Successful?.length ?? 0;
46
+
47
+ // Index buffer by Id so we can attach event/processId to failures.
48
+ const bufferById = new Map(buffer.map((b) => [b.Id, b]));
49
+
50
+ if (failedCount > 0) {
51
+ console.error(
52
+ `[QueuerUtil] SendMessageBatch partial failure: ${failedCount}/${bufferSize} failed`,
53
+ {
54
+ queueUrl,
55
+ bufferSize,
56
+ successCount,
57
+ failedCount,
58
+ failed: result.Failed.map((f) => {
59
+ const bufEntry = bufferById.get(f.Id);
60
+ const summary = bufEntry
61
+ ? summarizeMessageBody(bufEntry.MessageBody)
62
+ : {};
63
+ return {
64
+ Id: f.Id,
65
+ Code: f.Code,
66
+ SenderFault: f.SenderFault,
67
+ Message: f.Message,
68
+ ...summary,
69
+ };
70
+ }),
71
+ }
72
+ );
73
+ } else if (successCount > 0) {
74
+ // Include a compact per-entry summary so operators can correlate
75
+ // "which send contained which logical message" during incident triage.
76
+ const entries = result.Successful.map((s) => {
77
+ const bufEntry = bufferById.get(s.Id);
78
+ const summary = bufEntry
79
+ ? summarizeMessageBody(bufEntry.MessageBody)
80
+ : {};
81
+ return { MessageId: s.MessageId, ...summary };
82
+ });
83
+ console.log(
84
+ `[QueuerUtil] SendMessageBatch ok: ${successCount}/${bufferSize} to ${queueUrl}`,
85
+ { entries }
86
+ );
87
+ }
88
+
89
+ return result;
90
+ };
91
+
21
92
  const QueuerUtil = {
22
93
  send: async (message, queueUrl) => {
23
94
  const command = new SendMessageCommand({
24
95
  MessageBody: JSON.stringify(message),
25
96
  QueueUrl: queueUrl,
26
97
  });
27
- return sqs.send(command);
98
+ const result = await sqs.send(command);
99
+ console.log(
100
+ `[QueuerUtil] SendMessage ok: MessageId=${result?.MessageId} to ${queueUrl}`
101
+ );
102
+ return result;
28
103
  },
29
104
 
30
105
  batchSend: async (entries = [], queueUrl) => {
@@ -42,7 +117,8 @@ const QueuerUtil = {
42
117
  Entries: buffer,
43
118
  QueueUrl: queueUrl,
44
119
  });
45
- await sqs.send(command);
120
+ const result = await sqs.send(command);
121
+ inspectBatchResult(result, queueUrl, buffer);
46
122
  // Purge the buffer
47
123
  buffer.splice(0, buffer.length);
48
124
  }
@@ -54,7 +130,8 @@ const QueuerUtil = {
54
130
  Entries: buffer,
55
131
  QueueUrl: queueUrl,
56
132
  });
57
- return sqs.send(command);
133
+ const result = await sqs.send(command);
134
+ return inspectBatchResult(result, queueUrl, buffer);
58
135
  }
59
136
 
60
137
  // If we're exact... just return an empty object for now