@friggframework/core 2.0.0-next.80 → 2.0.0-next.82

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 (40) hide show
  1. package/CLAUDE.md +8 -0
  2. package/generated/prisma-mongodb/edge.js +3 -3
  3. package/generated/prisma-mongodb/index.d.ts +2 -2
  4. package/generated/prisma-mongodb/index.js +9 -9
  5. package/generated/prisma-mongodb/{query-engine-debian-openssl-3.0.x → libquery_engine-debian-openssl-3.0.x.so.node} +0 -0
  6. package/generated/prisma-mongodb/{query-engine-rhel-openssl-3.0.x → libquery_engine-rhel-openssl-3.0.x.so.node} +0 -0
  7. package/generated/prisma-mongodb/package.json +1 -1
  8. package/generated/prisma-mongodb/runtime/library.js +146 -0
  9. package/generated/prisma-mongodb/schema.prisma +7 -1
  10. package/generated/prisma-mongodb/wasm.js +3 -3
  11. package/generated/prisma-postgresql/edge.js +3 -3
  12. package/generated/prisma-postgresql/index.d.ts +6 -2
  13. package/generated/prisma-postgresql/index.js +9 -9
  14. package/generated/prisma-postgresql/{query-engine-debian-openssl-3.0.x → libquery_engine-debian-openssl-3.0.x.so.node} +0 -0
  15. package/generated/prisma-postgresql/{query-engine-rhel-openssl-3.0.x → libquery_engine-rhel-openssl-3.0.x.so.node} +0 -0
  16. package/generated/prisma-postgresql/package.json +1 -1
  17. package/generated/prisma-postgresql/runtime/library.js +146 -0
  18. package/generated/prisma-postgresql/schema.prisma +7 -1
  19. package/generated/prisma-postgresql/wasm.js +3 -3
  20. package/integrations/integration-router.js +27 -6
  21. package/integrations/repositories/process-repository-documentdb.js +68 -0
  22. package/integrations/repositories/process-repository-interface.js +46 -0
  23. package/integrations/repositories/process-repository-mongo.js +72 -0
  24. package/integrations/repositories/process-repository-postgres.js +163 -0
  25. package/integrations/repositories/process-update-ops-shared.js +112 -0
  26. package/integrations/use-cases/update-process-metrics.js +106 -102
  27. package/integrations/use-cases/update-process-state.js +58 -19
  28. package/modules/module.js +3 -1
  29. package/modules/requester/requester.js +145 -37
  30. package/modules/use-cases/get-module-instance-from-type.js +4 -1
  31. package/modules/use-cases/process-authorization-callback.js +49 -5
  32. package/package.json +5 -5
  33. package/prisma-mongodb/schema.prisma +7 -1
  34. package/prisma-postgresql/migrations/20260422120000_add_entity_data_column/migration.sql +10 -0
  35. package/prisma-postgresql/migrations/20260422120001_create_process_table/migration.sql +48 -0
  36. package/prisma-postgresql/schema.prisma +7 -1
  37. package/generated/prisma-mongodb/runtime/binary.d.ts +0 -1
  38. package/generated/prisma-mongodb/runtime/binary.js +0 -289
  39. package/generated/prisma-postgresql/runtime/binary.d.ts +0 -1
  40. package/generated/prisma-postgresql/runtime/binary.js +0 -289
@@ -1,37 +1,28 @@
1
- /**
2
- TODO:
3
- This implementation contains a race condition in the `execute` method. When multiple concurrent processes call this method on the same process record, they'll each read the current state, modify it independently, and then save - potentially overwriting each other's changes.
4
-
5
- For example:
6
- ```
7
- Thread 1: reads process with totalSynced=100
8
- Thread 2: reads process with totalSynced=100
9
- Thread 1: adds 50 → writes totalSynced=150
10
- Thread 2: adds 30 → writes totalSynced=130 (overwrites Thread 1's update!)
11
- ```
12
-
13
- Consider implementing one of these patterns:
14
- 1. Database transactions with row locking
15
- 2. Optimistic concurrency control with version numbers
16
- 3. Atomic update operations (e.g., `$inc` in MongoDB)
17
- 4. A FIFO queue for process updates (as described in the PROCESS_MANAGEMENT_QUEUE_SPEC.md)
18
-
19
- The current approach will lead to lost updates and inconsistent metrics during concurrent processing.
20
-
21
- */
22
-
23
1
  /**
24
2
  * UpdateProcessMetrics Use Case
25
3
  *
26
- * Updates process metrics, calculates aggregates, and computes estimated completion time.
27
- * Optionally broadcasts progress via WebSocket service if provided.
4
+ * Updates process metrics atomically via
5
+ * `processRepository.applyProcessUpdate`. This is the race-safe
6
+ * replacement for the original read-modify-write implementation — the
7
+ * long-standing TODO about lost updates under concurrent writers is
8
+ * now resolved.
9
+ *
10
+ * Split into two phases:
28
11
  *
29
- * Design Philosophy:
30
- * - Metrics are cumulative (add to existing counts)
31
- * - Performance metrics calculated automatically (duration, records/sec)
32
- * - ETA computed based on current progress
33
- * - Error history limited to last 100 entries
34
- * - WebSocket broadcasting is optional (DI pattern)
12
+ * 1. Atomic phase — counters and bounded error history. Uses
13
+ * $inc / $push+$slice (Mongo/DocumentDB) or jsonb_set with
14
+ * arithmetic expressions (Postgres) in a single UPDATE ... RETURNING
15
+ * so concurrent callers serialize at the DB layer.
16
+ *
17
+ * 2. Derived-fields phase duration, recordsPerSecond,
18
+ * estimatedCompletion. Computed from the post-atomic snapshot and
19
+ * written via the legacy (non-atomic) `update()` method.
20
+ * Intentionally best-effort: under concurrent writers they reflect
21
+ * "whichever handler wrote last" — the same semantics they had
22
+ * before and all they've ever guaranteed. Preserved for backward
23
+ * compatibility with consumers (UI, WebSocket listeners).
24
+ *
25
+ * Optionally broadcasts progress via WebSocket service if provided.
35
26
  *
36
27
  * @example
37
28
  * const updateMetrics = new UpdateProcessMetrics({ processRepository, websocketService });
@@ -60,15 +51,14 @@ class UpdateProcessMetrics {
60
51
  * Execute the use case to update process metrics
61
52
  * @param {string} processId - Process ID to update
62
53
  * @param {Object} metricsUpdate - Metrics to add/update
63
- * @param {number} [metricsUpdate.processed=0] - Number of records processed in this batch
64
- * @param {number} [metricsUpdate.success=0] - Number of successful records
65
- * @param {number} [metricsUpdate.errors=0] - Number of failed records
54
+ * @param {number} [metricsUpdate.processed=0] - Records processed in this batch
55
+ * @param {number} [metricsUpdate.success=0] - Successful records
56
+ * @param {number} [metricsUpdate.errors=0] - Failed records
66
57
  * @param {Array} [metricsUpdate.errorDetails=[]] - Error details array
67
58
  * @returns {Promise<Object>} Updated process record
68
59
  * @throws {Error} If process not found or update fails
69
60
  */
70
61
  async execute(processId, metricsUpdate) {
71
- // Validate inputs
72
62
  if (!processId || typeof processId !== 'string') {
73
63
  throw new Error('processId must be a non-empty string');
74
64
  }
@@ -76,87 +66,103 @@ class UpdateProcessMetrics {
76
66
  throw new Error('metricsUpdate must be an object');
77
67
  }
78
68
 
79
- // Retrieve current process
80
- const process = await this.processRepository.findById(processId);
81
- if (!process) {
82
- throw new Error(`Process not found: ${processId}`);
83
- }
84
-
85
- // Get current context and results
86
- const context = process.context || {};
87
- const results = process.results || { aggregateData: {} };
88
-
89
- // Initialize nested objects if not present
90
- if (!results.aggregateData) {
91
- results.aggregateData = {};
92
- }
93
-
94
- // Update context counters (cumulative)
95
- context.processedRecords =
96
- (context.processedRecords || 0) + (metricsUpdate.processed || 0);
69
+ // Phase 1: atomic increments + bounded error history.
70
+ const increment = {};
71
+ const processed = metricsUpdate.processed || 0;
72
+ const success = metricsUpdate.success || 0;
73
+ const errors = metricsUpdate.errors || 0;
74
+ if (processed) increment['context.processedRecords'] = processed;
75
+ if (success) increment['results.aggregateData.totalSynced'] = success;
76
+ if (errors) increment['results.aggregateData.totalFailed'] = errors;
97
77
 
98
- // Update results aggregates (cumulative)
99
- results.aggregateData.totalSynced =
100
- (results.aggregateData.totalSynced || 0) +
101
- (metricsUpdate.success || 0);
102
- results.aggregateData.totalFailed =
103
- (results.aggregateData.totalFailed || 0) +
104
- (metricsUpdate.errors || 0);
105
-
106
- // Append error details (limited to last 100)
78
+ const pushSlice = {};
107
79
  if (
108
- metricsUpdate.errorDetails &&
80
+ Array.isArray(metricsUpdate.errorDetails) &&
109
81
  metricsUpdate.errorDetails.length > 0
110
82
  ) {
111
- results.aggregateData.errors = [
112
- ...(results.aggregateData.errors || []),
113
- ...metricsUpdate.errorDetails,
114
- ].slice(-100); // Keep only last 100 errors
83
+ pushSlice['results.aggregateData.errors'] = {
84
+ values: metricsUpdate.errorDetails,
85
+ keepLast: 100,
86
+ };
115
87
  }
116
88
 
117
- // Calculate performance metrics
118
- const startTime = new Date(context.startTime || process.createdAt);
119
- const elapsed = Date.now() - startTime.getTime();
120
- results.aggregateData.duration = elapsed;
121
-
122
- if (elapsed > 0 && context.processedRecords > 0) {
123
- results.aggregateData.recordsPerSecond =
124
- context.processedRecords / (elapsed / 1000);
125
- } else {
126
- results.aggregateData.recordsPerSecond = 0;
127
- }
89
+ const hasAtomicWork =
90
+ Object.keys(increment).length > 0 ||
91
+ Object.keys(pushSlice).length > 0;
128
92
 
129
- // Calculate ETA if we know total
130
- if (context.totalRecords > 0 && context.processedRecords > 0) {
131
- const remaining = context.totalRecords - context.processedRecords;
132
- if (results.aggregateData.recordsPerSecond > 0) {
133
- const etaMs =
134
- (remaining / results.aggregateData.recordsPerSecond) * 1000;
135
- const eta = new Date(Date.now() + etaMs);
136
- context.estimatedCompletion = eta.toISOString();
137
- }
138
- }
139
-
140
- // Prepare updates
141
- const updates = {
142
- context,
143
- results,
144
- };
145
-
146
- // Persist updates
147
93
  let updatedProcess;
148
94
  try {
149
- updatedProcess = await this.processRepository.update(
150
- processId,
151
- updates
152
- );
95
+ if (hasAtomicWork) {
96
+ updatedProcess = await this.processRepository.applyProcessUpdate(
97
+ processId,
98
+ { increment, pushSlice }
99
+ );
100
+ } else {
101
+ // All-zero update (e.g., empty batch) — nothing to persist;
102
+ // just read current state for the derived-fields pass.
103
+ updatedProcess = await this.processRepository.findById(
104
+ processId
105
+ );
106
+ }
153
107
  } catch (error) {
154
108
  throw new Error(
155
109
  `Failed to update process metrics: ${error.message}`
156
110
  );
157
111
  }
158
112
 
159
- // Broadcast progress via WebSocket (if service provided)
113
+ if (!updatedProcess) {
114
+ throw new Error(`Process not found: ${processId}`);
115
+ }
116
+
117
+ // Phase 2: derived metrics (non-atomic, best-effort). Preserved
118
+ // for backward compatibility — these were always stale under
119
+ // concurrent writers even before this refactor.
120
+ const context = updatedProcess.context || {};
121
+ const results = updatedProcess.results || { aggregateData: {} };
122
+ if (!results.aggregateData) results.aggregateData = {};
123
+
124
+ if (context.processedRecords > 0 || context.totalRecords > 0) {
125
+ const startTime = new Date(
126
+ context.startTime || updatedProcess.createdAt
127
+ );
128
+ const elapsed = Date.now() - startTime.getTime();
129
+ results.aggregateData.duration = elapsed;
130
+
131
+ if (elapsed > 0 && context.processedRecords > 0) {
132
+ results.aggregateData.recordsPerSecond =
133
+ context.processedRecords / (elapsed / 1000);
134
+ } else {
135
+ results.aggregateData.recordsPerSecond = 0;
136
+ }
137
+
138
+ if (context.totalRecords > 0 && context.processedRecords > 0) {
139
+ const remaining =
140
+ context.totalRecords - context.processedRecords;
141
+ if (results.aggregateData.recordsPerSecond > 0) {
142
+ const etaMs =
143
+ (remaining / results.aggregateData.recordsPerSecond) *
144
+ 1000;
145
+ const eta = new Date(Date.now() + etaMs);
146
+ context.estimatedCompletion = eta.toISOString();
147
+ }
148
+ }
149
+
150
+ try {
151
+ updatedProcess = await this.processRepository.update(
152
+ processId,
153
+ { context, results }
154
+ );
155
+ } catch (error) {
156
+ // Derived-field write failures are NON-FATAL — atomic
157
+ // counters from phase 1 already landed. Log and return the
158
+ // post-atomic snapshot.
159
+ console.error(
160
+ '[UpdateProcessMetrics] derived-fields write failed (non-fatal):',
161
+ error.message
162
+ );
163
+ }
164
+ }
165
+
160
166
  if (this.websocketService) {
161
167
  await this._broadcastProgress(updatedProcess);
162
168
  }
@@ -167,7 +173,6 @@ class UpdateProcessMetrics {
167
173
  /**
168
174
  * Broadcast progress update via WebSocket
169
175
  * @private
170
- * @param {Object} process - Updated process record
171
176
  */
172
177
  async _broadcastProgress(process) {
173
178
  try {
@@ -192,7 +197,6 @@ class UpdateProcessMetrics {
192
197
  },
193
198
  });
194
199
  } catch (error) {
195
- // Log but don't fail the update if WebSocket broadcast fails
196
200
  console.error('Failed to broadcast process progress:', error);
197
201
  }
198
202
  }
@@ -54,30 +54,69 @@ class UpdateProcessState {
54
54
  throw new Error('contextUpdates must be an object');
55
55
  }
56
56
 
57
- // Retrieve current process
58
- const process = await this.processRepository.findById(processId);
59
- if (!process) {
60
- throw new Error(`Process not found: ${processId}`);
61
- }
57
+ // Route through the atomic path when the repo supports it AND we
58
+ // have context keys to set. The atomic path writes the state
59
+ // column + the context field-sets in one DB round trip without
60
+ // read-modify-write, so a concurrent counter bump from
61
+ // UpdateProcessMetrics can't clobber our flags (e.g. `fetchDone`)
62
+ // or vice versa.
63
+ //
64
+ // Each context update key becomes a `set` at path
65
+ // `context.<key>` — matching the prior semantics of a shallow
66
+ // top-level merge (sub-objects were and still are replaced
67
+ // whole, not deep-merged).
68
+ const hasContextKeys =
69
+ contextUpdates && Object.keys(contextUpdates).length > 0;
62
70
 
63
- // Prepare updates
64
- const updates = {
65
- state: newState,
66
- };
67
-
68
- // Merge context updates if provided
69
- if (contextUpdates && Object.keys(contextUpdates).length > 0) {
70
- updates.context = {
71
- ...process.context,
72
- ...contextUpdates,
73
- };
71
+ if (
72
+ hasContextKeys &&
73
+ typeof this.processRepository.applyProcessUpdate === 'function'
74
+ ) {
75
+ const set = {};
76
+ for (const [key, value] of Object.entries(contextUpdates)) {
77
+ set[`context.${key}`] = value;
78
+ }
79
+ try {
80
+ const updated = await this.processRepository.applyProcessUpdate(
81
+ processId,
82
+ { set, newState }
83
+ );
84
+ if (!updated) {
85
+ throw new Error(`Process not found: ${processId}`);
86
+ }
87
+ return updated;
88
+ } catch (error) {
89
+ throw new Error(
90
+ `Failed to update process state: ${error.message}`
91
+ );
92
+ }
74
93
  }
75
94
 
76
- // Persist updates
95
+ // Legacy path (no contextUpdates or repo lacks applyProcessUpdate):
96
+ // preserve the original read-merge-write semantics for backward
97
+ // compatibility with any custom repos. Wrap the full read+write
98
+ // in try/catch so a findById error surfaces under the same
99
+ // "Failed to update process state" message as a write failure.
77
100
  try {
78
- const updatedProcess = await this.processRepository.update(processId, updates);
79
- return updatedProcess;
101
+ const process = await this.processRepository.findById(processId);
102
+ if (!process) {
103
+ throw new Error(`Process not found: ${processId}`);
104
+ }
105
+
106
+ const updates = { state: newState };
107
+ if (hasContextKeys) {
108
+ updates.context = {
109
+ ...process.context,
110
+ ...contextUpdates,
111
+ };
112
+ }
113
+
114
+ return await this.processRepository.update(processId, updates);
80
115
  } catch (error) {
116
+ // Re-throw "Process not found" as-is; wrap other errors.
117
+ if (error.message && error.message.startsWith('Process not found')) {
118
+ throw error;
119
+ }
81
120
  throw new Error(`Failed to update process state: ${error.message}`);
82
121
  }
83
122
  }
package/modules/module.js CHANGED
@@ -20,8 +20,9 @@ class Module extends Delegate {
20
20
  * @param {Object} params.definition The definition of the Api Module
21
21
  * @param {string} params.userId The user id
22
22
  * @param {Object} params.entity The entity record from the database
23
+ * @param {string} [params.state] Optional OAuth state value forwarded to the API client (round-trips through the OAuth provider).
23
24
  */
24
- constructor({ definition, userId = null, entity: entityObj = null }) {
25
+ constructor({ definition, userId = null, entity: entityObj = null, state = null }) {
25
26
  super({ definition, userId, entity: entityObj });
26
27
 
27
28
  this.validateDefinition(definition);
@@ -46,6 +47,7 @@ class Module extends Delegate {
46
47
  const apiParams = {
47
48
  ...this.definition.env,
48
49
  delegate: this,
50
+ ...(state ? { state } : {}),
49
51
  ...(this.credential?.data
50
52
  ? this.apiParamsFromCredential(this.credential.data)
51
53
  : {}), // Handle case when credential is undefined
@@ -3,6 +3,8 @@ const { Delegate } = require('../../core');
3
3
  const { FetchError } = require('../../errors');
4
4
  const { get } = require('../../assertions');
5
5
 
6
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
7
+
6
8
  class Requester extends Delegate {
7
9
  constructor(params) {
8
10
  super(params);
@@ -13,6 +15,30 @@ class Requester extends Delegate {
13
15
  this.delegateTypes.push(this.DLGT_INVALID_AUTH);
14
16
  this.agent = get(params, 'agent', null);
15
17
 
18
+ // Per-attempt HTTP timeout. Without this the framework called fetch()
19
+ // with no AbortController and no timeout — a silently-hung TCP
20
+ // connection (server accepts but never responds) blocked the calling
21
+ // promise forever, cascading into stalled batches, stalled syncs,
22
+ // and worker-lambda timeouts.
23
+ //
24
+ // Configuration precedence:
25
+ // 1. Instance param: new Requester({ requestTimeoutMs: 30_000 })
26
+ // 2. Class static: static requestTimeoutMs = 30_000
27
+ // 3. Default: DEFAULT_REQUEST_TIMEOUT_MS (60s)
28
+ //
29
+ // Pass 0 (or null) to disable the timeout entirely — reserved for
30
+ // test doubles and documented long-running endpoints.
31
+ // Intentionally NOT using `get(params, ...)` here — the Frigg
32
+ // `get` helper throws RequiredPropertyError if the key is missing
33
+ // and no default is provided, which would collide with the fall-
34
+ // through to the class-level static override.
35
+ const instanceTimeout = params?.requestTimeoutMs;
36
+ this.requestTimeoutMs =
37
+ instanceTimeout !== undefined && instanceTimeout !== null
38
+ ? instanceTimeout
39
+ : this.constructor.requestTimeoutMs ??
40
+ DEFAULT_REQUEST_TIMEOUT_MS;
41
+
16
42
  // Allow passing in the fetch function
17
43
  // Instance methods can use this.fetch without differentiating
18
44
  this.fetch = get(params, 'fetch', fetch);
@@ -48,52 +74,134 @@ class Requester extends Delegate {
48
74
 
49
75
  if (this.agent) options.agent = this.agent;
50
76
 
51
- let response;
77
+ // Per-attempt timeout — fresh AbortController per call so the retry
78
+ // recursion (with its own backoff sleeps) always gets a clean
79
+ // signal. Timer is cleared in the finally block regardless of
80
+ // outcome.
81
+ const timeoutMs = this.requestTimeoutMs;
82
+ const controller = timeoutMs > 0 ? new AbortController() : null;
83
+ const timeoutHandle = controller
84
+ ? setTimeout(() => controller.abort(), timeoutMs)
85
+ : null;
86
+ const fetchOptions = controller
87
+ ? { ...options, signal: controller.signal }
88
+ : options;
89
+
90
+ // Timer must stay active through body consumption. node-fetch v2
91
+ // resolves the fetch() promise when headers arrive, not when the
92
+ // body is fully read — so a server that sends headers and then
93
+ // stalls the body would still hang parsedBody() or
94
+ // FetchError.create()'s response.text() call. We clear the timer
95
+ // only after the body is fully consumed (success path) or
96
+ // deliberately before each recursive retry so the new attempt
97
+ // starts with its own fresh timer.
98
+ let timerCleared = false;
99
+ const clearRequestTimer = () => {
100
+ if (!timerCleared && timeoutHandle) {
101
+ clearTimeout(timeoutHandle);
102
+ timerCleared = true;
103
+ }
104
+ };
105
+
52
106
  try {
53
- response = await this.fetch(encodedUrl, options);
54
- } catch (e) {
55
- if (e.code === 'ECONNRESET' && i < this.backOff.length) {
107
+ let response;
108
+ try {
109
+ response = await this.fetch(encodedUrl, fetchOptions);
110
+ } catch (e) {
111
+ // AbortController fires AbortError (name) / ETIMEDOUT-shaped
112
+ // errors (type on node-fetch) when we hit the timeout. No
113
+ // retry on timeout: a slow endpoint is a downstream problem,
114
+ // and each retry would wait another `timeoutMs` before giving
115
+ // up — amplifying the hang into a per-record multi-minute
116
+ // stall at batch scale.
117
+ const isTimeout =
118
+ e?.name === 'AbortError' || e?.type === 'aborted';
119
+ if (e?.code === 'ECONNRESET' && i < this.backOff.length) {
120
+ clearRequestTimer();
121
+ const delay = this.backOff[i] * 1000;
122
+ await new Promise((resolve) =>
123
+ setTimeout(resolve, delay)
124
+ );
125
+ return this._request(url, options, i + 1);
126
+ }
127
+ const fetchError = await FetchError.create({
128
+ resource: encodedUrl,
129
+ init: options,
130
+ responseBody: isTimeout
131
+ ? `Request timed out after ${timeoutMs}ms`
132
+ : e,
133
+ });
134
+ if (isTimeout) {
135
+ // Flag + machine-readable fields so callers can
136
+ // distinguish a timeout from a generic network error
137
+ // without parsing the message (which FetchError
138
+ // sanitizes outside of STAGE=dev).
139
+ fetchError.isTimeout = true;
140
+ fetchError.timeoutMs = timeoutMs;
141
+ }
142
+ throw fetchError;
143
+ }
144
+
145
+ const { status } = response;
146
+
147
+ // If the status is retriable and there are back off requests left, retry the request
148
+ if ((status === 429 || status >= 500) && i < this.backOff.length) {
149
+ clearRequestTimer();
56
150
  const delay = this.backOff[i] * 1000;
57
151
  await new Promise((resolve) => setTimeout(resolve, delay));
58
152
  return this._request(url, options, i + 1);
59
- }
60
- throw await FetchError.create({
61
- resource: encodedUrl,
62
- init: options,
63
- responseBody: e,
64
- });
65
- }
66
- const { status } = response;
67
-
68
- // If the status is retriable and there are back off requests left, retry the request
69
- if ((status === 429 || status >= 500) && i < this.backOff.length) {
70
- const delay = this.backOff[i] * 1000;
71
- await new Promise((resolve) => setTimeout(resolve, delay));
72
- return this._request(url, options, i + 1);
73
- } else if (status === 401) {
74
- if (!this.isRefreshable || this.refreshCount > 0) {
75
- await this.notify(this.DLGT_INVALID_AUTH);
76
- } else {
77
- this.refreshCount++;
78
- const refreshSucceeded = await this.refreshAuth();
79
- if (refreshSucceeded) {
80
- return this._request(url, options, i + 1);
153
+ } else if (status === 401) {
154
+ if (!this.isRefreshable || this.refreshCount > 0) {
155
+ await this.notify(this.DLGT_INVALID_AUTH);
156
+ } else {
157
+ this.refreshCount++;
158
+ const refreshSucceeded = await this.refreshAuth();
159
+ if (refreshSucceeded) {
160
+ clearRequestTimer();
161
+ return this._request(url, options, i + 1);
162
+ }
81
163
  }
82
164
  }
83
- }
84
165
 
85
- // If the error wasn't retried, throw.
86
- if (status >= 400) {
87
- throw await FetchError.create({
88
- resource: encodedUrl,
89
- init: options,
90
- response,
91
- });
166
+ // If the error wasn't retried, throw. FetchError.create reads
167
+ // the response body (response.text()) — timer must still be
168
+ // alive to catch a stalled body stream.
169
+ if (status >= 400) {
170
+ const fetchError = await FetchError.create({
171
+ resource: encodedUrl,
172
+ init: options,
173
+ response,
174
+ });
175
+ throw this._maybeFlagTimeoutDuringBodyRead(
176
+ fetchError,
177
+ timeoutMs
178
+ );
179
+ }
180
+
181
+ // parsedBody consumes the response body stream. If the server
182
+ // stalls mid-stream the timer (still armed) aborts it.
183
+ return options.returnFullRes
184
+ ? response
185
+ : await this.parsedBody(response);
186
+ } catch (e) {
187
+ // If the abort fired during body consumption, node-fetch emits
188
+ // the error as an AbortError on the body stream. Surface the
189
+ // same isTimeout flag callers use for header-phase timeouts.
190
+ throw this._maybeFlagTimeoutDuringBodyRead(e, timeoutMs);
191
+ } finally {
192
+ clearRequestTimer();
92
193
  }
194
+ }
93
195
 
94
- return options.returnFullRes
95
- ? response
96
- : await this.parsedBody(response);
196
+ _maybeFlagTimeoutDuringBodyRead(err, timeoutMs) {
197
+ if (!err || typeof err !== 'object') return err;
198
+ if (err.isTimeout) return err;
199
+ const isAbort =
200
+ err.name === 'AbortError' || err.type === 'aborted';
201
+ if (!isAbort) return err;
202
+ err.isTimeout = true;
203
+ err.timeoutMs = timeoutMs;
204
+ return err;
97
205
  }
98
206
 
99
207
  async _get(options) {
@@ -13,8 +13,10 @@ class GetModuleInstanceFromType {
13
13
  * Retrieve a Module instance for a given user and entity/module type.
14
14
  * @param {string} userId
15
15
  * @param {string} type – human-readable module/entity type (e.g. "Hubspot")
16
+ * @param {Object} [options]
17
+ * @param {string} [options.state] – optional OAuth state value to be forwarded to the API client (round-trips through the OAuth provider).
16
18
  */
17
- async execute(userId, type) {
19
+ async execute(userId, type, options = {}) {
18
20
  const moduleDefinition = this.moduleDefinitions.find(
19
21
  (def) => def.getName() === type
20
22
  );
@@ -24,6 +26,7 @@ class GetModuleInstanceFromType {
24
26
  return new Module({
25
27
  userId,
26
28
  definition: moduleDefinition,
29
+ state: options.state,
27
30
  });
28
31
  }
29
32
  }