@friggframework/core 2.0.0-next.80 → 2.0.0-next.81
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/CLAUDE.md +8 -0
- package/generated/prisma-mongodb/edge.js +3 -3
- package/generated/prisma-mongodb/index.d.ts +2 -2
- package/generated/prisma-mongodb/index.js +9 -9
- package/generated/prisma-mongodb/{query-engine-debian-openssl-3.0.x → libquery_engine-debian-openssl-3.0.x.so.node} +0 -0
- package/generated/prisma-mongodb/{query-engine-rhel-openssl-3.0.x → libquery_engine-rhel-openssl-3.0.x.so.node} +0 -0
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/runtime/library.js +146 -0
- package/generated/prisma-mongodb/schema.prisma +7 -1
- package/generated/prisma-mongodb/wasm.js +3 -3
- package/generated/prisma-postgresql/edge.js +3 -3
- package/generated/prisma-postgresql/index.d.ts +6 -2
- package/generated/prisma-postgresql/index.js +9 -9
- package/generated/prisma-postgresql/{query-engine-debian-openssl-3.0.x → libquery_engine-debian-openssl-3.0.x.so.node} +0 -0
- package/generated/prisma-postgresql/{query-engine-rhel-openssl-3.0.x → libquery_engine-rhel-openssl-3.0.x.so.node} +0 -0
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/runtime/library.js +146 -0
- package/generated/prisma-postgresql/schema.prisma +7 -1
- package/generated/prisma-postgresql/wasm.js +3 -3
- package/integrations/integration-router.js +2 -1
- package/integrations/repositories/process-repository-documentdb.js +68 -0
- package/integrations/repositories/process-repository-interface.js +46 -0
- package/integrations/repositories/process-repository-mongo.js +72 -0
- package/integrations/repositories/process-repository-postgres.js +163 -0
- package/integrations/repositories/process-update-ops-shared.js +112 -0
- package/integrations/use-cases/update-process-metrics.js +106 -102
- package/integrations/use-cases/update-process-state.js +58 -19
- package/modules/module.js +3 -1
- package/modules/requester/requester.js +145 -37
- package/modules/use-cases/get-module-instance-from-type.js +4 -1
- package/package.json +5 -5
- package/prisma-mongodb/schema.prisma +7 -1
- package/prisma-postgresql/migrations/20260422120000_add_entity_data_column/migration.sql +10 -0
- package/prisma-postgresql/migrations/20260422120001_create_process_table/migration.sql +48 -0
- package/prisma-postgresql/schema.prisma +7 -1
- package/generated/prisma-mongodb/runtime/binary.d.ts +0 -1
- package/generated/prisma-mongodb/runtime/binary.js +0 -289
- package/generated/prisma-postgresql/runtime/binary.d.ts +0 -1
- 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
|
|
27
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* -
|
|
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] -
|
|
64
|
-
* @param {number} [metricsUpdate.success=0] -
|
|
65
|
-
* @param {number} [metricsUpdate.errors=0] -
|
|
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
|
-
//
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
83
|
+
pushSlice['results.aggregateData.errors'] = {
|
|
84
|
+
values: metricsUpdate.errorDetails,
|
|
85
|
+
keepLast: 100,
|
|
86
|
+
};
|
|
115
87
|
}
|
|
116
88
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
//
|
|
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
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
}
|
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.81",
|
|
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.81",
|
|
42
|
+
"@friggframework/prettier-config": "2.0.0-next.81",
|
|
43
|
+
"@friggframework/test": "2.0.0-next.81",
|
|
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": "
|
|
83
|
+
"gitHead": "f928679326fe06cc56ac46e97cf268fe8f8e823e"
|
|
84
84
|
}
|
|
@@ -6,7 +6,13 @@ generator client {
|
|
|
6
6
|
provider = "prisma-client-js"
|
|
7
7
|
output = "../generated/prisma-mongodb"
|
|
8
8
|
binaryTargets = ["native", "rhel-openssl-3.0.x"] // native for local dev, rhel for Lambda deployment
|
|
9
|
-
|
|
9
|
+
// Library engine (default since Prisma 3.x): Rust query engine loads as a
|
|
10
|
+
// Node-API addon inside the same process. The binary engine forks a child
|
|
11
|
+
// query-engine subprocess and communicates over a local HTTP/IPC pipe with
|
|
12
|
+
// NO client-side timeout — a zombied child wedges the Node process until
|
|
13
|
+
// Lambda's 900s cap. Switching to library eliminates that entire class of
|
|
14
|
+
// silent hangs. See friggframework/frigg#580 for the investigation.
|
|
15
|
+
engineType = "library"
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
datasource db {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- AlterTable
|
|
2
|
+
-- Backfill the Entity.data JSONB field declared in schema.prisma but never
|
|
3
|
+
-- migrated. ModuleRepositoryPostgres.createEntity / findEntity use this
|
|
4
|
+
-- column to persist & rehydrate identifiers/details fields that fall
|
|
5
|
+
-- outside the six named columns (id, userId, credentialId, name,
|
|
6
|
+
-- moduleName, externalId). Without the column, any integration whose
|
|
7
|
+
-- getEntityDetails returns an extra field (e.g. `firm_subdomain`) causes
|
|
8
|
+
-- prisma.entity.create to throw P2022 at runtime.
|
|
9
|
+
ALTER TABLE "Entity"
|
|
10
|
+
ADD COLUMN IF NOT EXISTS "data" JSONB NOT NULL DEFAULT '{}';
|