@jackchuka/gql-ingest 1.3.0 ā 1.5.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.
- package/LICENSE +21 -0
- package/README.md +24 -0
- package/bin/cli.js +213 -35
- package/dist/dependency-resolver.d.ts +2 -1
- package/dist/dependency-resolver.d.ts.map +1 -1
- package/dist/mapper.d.ts +4 -1
- package/dist/mapper.d.ts.map +1 -1
- package/dist/metrics.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +43 -7
- package/src/dependency-resolver.test.ts +15 -1
- package/src/dependency-resolver.ts +6 -2
- package/src/mapper.test.ts +221 -0
- package/src/mapper.ts +174 -13
- package/src/metrics.ts +18 -10
package/src/mapper.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
+
import { parse, DocumentNode, VariableDefinitionNode } from "graphql";
|
|
3
4
|
import { readCsvFile, CsvRow } from "./csv-reader";
|
|
4
5
|
import { GraphQLClientWrapper } from "./graphql-client";
|
|
5
6
|
import { MetricsCollector } from "./metrics";
|
|
@@ -29,12 +30,39 @@ export class DataMapper {
|
|
|
29
30
|
this.verbose = verbose;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
discoverMappings(configDir: string): string[] {
|
|
33
|
+
discoverMappings(configDir: string, entityFilter?: string[]): string[] {
|
|
33
34
|
const mappingsPath = path.resolve(this.basePath, configDir, "mappings");
|
|
34
35
|
|
|
35
36
|
try {
|
|
36
37
|
const files = fs.readdirSync(mappingsPath);
|
|
37
|
-
|
|
38
|
+
let jsonFiles = files.filter((file) => file.endsWith(".json"));
|
|
39
|
+
|
|
40
|
+
// Apply entity filter if provided
|
|
41
|
+
if (entityFilter && entityFilter.length > 0) {
|
|
42
|
+
const requestedEntities = new Set(entityFilter);
|
|
43
|
+
const foundEntities = new Set<string>();
|
|
44
|
+
|
|
45
|
+
jsonFiles = jsonFiles.filter((file) => {
|
|
46
|
+
const entityName = path.basename(file, ".json");
|
|
47
|
+
if (requestedEntities.has(entityName)) {
|
|
48
|
+
foundEntities.add(entityName);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Check for requested entities that were not found
|
|
55
|
+
const notFound = entityFilter.filter((e) => !foundEntities.has(e));
|
|
56
|
+
if (notFound.length > 0) {
|
|
57
|
+
console.warn(
|
|
58
|
+
`Warning: The following entities were not found in mappings: ${notFound.join(
|
|
59
|
+
", "
|
|
60
|
+
)}`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
jsonFiles.sort(); // Alphabetical order for consistent processing
|
|
38
66
|
|
|
39
67
|
console.log(
|
|
40
68
|
`Discovered ${jsonFiles.length} mapping files: ${jsonFiles.join(", ")}`
|
|
@@ -104,24 +132,33 @@ export class DataMapper {
|
|
|
104
132
|
retryConfig?: RetryConfig
|
|
105
133
|
): Promise<void> {
|
|
106
134
|
const totalRows = csvData.length;
|
|
107
|
-
|
|
135
|
+
const variableTypes = this.extractVariableTypes(mutation);
|
|
136
|
+
|
|
108
137
|
for (let i = 0; i < csvData.length; i++) {
|
|
109
138
|
const row = csvData[i];
|
|
110
|
-
const variables = this.mapCsvRowToVariables(row, mapping);
|
|
139
|
+
const variables = this.mapCsvRowToVariables(row, mapping, variableTypes);
|
|
111
140
|
|
|
112
141
|
try {
|
|
113
142
|
await this.client.executeMutation(mutation, variables, retryConfig);
|
|
114
143
|
this.metrics.recordSuccess(entityName);
|
|
115
|
-
|
|
144
|
+
|
|
116
145
|
// Show progress every 10% or at the end (only in non-verbose mode)
|
|
117
|
-
if (
|
|
146
|
+
if (
|
|
147
|
+
!this.verbose &&
|
|
148
|
+
((i + 1) % Math.max(1, Math.floor(totalRows / 10)) === 0 ||
|
|
149
|
+
i === totalRows - 1)
|
|
150
|
+
) {
|
|
118
151
|
const progress = (((i + 1) / totalRows) * 100).toFixed(1);
|
|
119
152
|
console.log(`š Progress: ${i + 1}/${totalRows} (${progress}%) ā`);
|
|
120
153
|
}
|
|
121
154
|
} catch (error) {
|
|
122
155
|
this.metrics.recordFailure(entityName);
|
|
123
156
|
if (!this.verbose) {
|
|
124
|
-
console.error(
|
|
157
|
+
console.error(
|
|
158
|
+
`ā Failed to create entity for row ${i + 1}:`,
|
|
159
|
+
row,
|
|
160
|
+
error
|
|
161
|
+
);
|
|
125
162
|
}
|
|
126
163
|
}
|
|
127
164
|
}
|
|
@@ -140,6 +177,9 @@ export class DataMapper {
|
|
|
140
177
|
`Processing ${csvData.length} rows with concurrency: ${concurrency}`
|
|
141
178
|
);
|
|
142
179
|
|
|
180
|
+
// Extract variable types once for all rows
|
|
181
|
+
const variableTypes = this.extractVariableTypes(mutation);
|
|
182
|
+
|
|
143
183
|
// Split data into chunks for concurrent processing
|
|
144
184
|
const chunks = this.chunkArray(csvData, concurrency);
|
|
145
185
|
let processedCount = 0;
|
|
@@ -148,10 +188,18 @@ export class DataMapper {
|
|
|
148
188
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
149
189
|
const chunk = chunks[chunkIndex];
|
|
150
190
|
const promises = chunk.map(async (row) => {
|
|
151
|
-
const variables = this.mapCsvRowToVariables(
|
|
191
|
+
const variables = this.mapCsvRowToVariables(
|
|
192
|
+
row,
|
|
193
|
+
mapping,
|
|
194
|
+
variableTypes
|
|
195
|
+
);
|
|
152
196
|
|
|
153
197
|
try {
|
|
154
|
-
const result = await this.client.executeMutation(
|
|
198
|
+
const result = await this.client.executeMutation(
|
|
199
|
+
mutation,
|
|
200
|
+
variables,
|
|
201
|
+
retryConfig
|
|
202
|
+
);
|
|
155
203
|
this.metrics.recordSuccess(entityName);
|
|
156
204
|
return { success: true, result, row };
|
|
157
205
|
} catch (error) {
|
|
@@ -166,7 +214,7 @@ export class DataMapper {
|
|
|
166
214
|
// Count successes and failures in this chunk
|
|
167
215
|
let chunkSuccesses = 0;
|
|
168
216
|
let chunkFailures = 0;
|
|
169
|
-
|
|
217
|
+
|
|
170
218
|
results.forEach((result) => {
|
|
171
219
|
if (result.status === "fulfilled") {
|
|
172
220
|
const { success, error, row } = result.value;
|
|
@@ -189,7 +237,11 @@ export class DataMapper {
|
|
|
189
237
|
// Show progress update (only in non-verbose mode)
|
|
190
238
|
if (!this.verbose) {
|
|
191
239
|
const progress = ((processedCount / totalRows) * 100).toFixed(1);
|
|
192
|
-
console.log(
|
|
240
|
+
console.log(
|
|
241
|
+
`š Progress: ${processedCount}/${totalRows} (${progress}%) - Chunk ${
|
|
242
|
+
chunkIndex + 1
|
|
243
|
+
}: ${chunkSuccesses} ā, ${chunkFailures} ā`
|
|
244
|
+
);
|
|
193
245
|
}
|
|
194
246
|
}
|
|
195
247
|
}
|
|
@@ -204,19 +256,128 @@ export class DataMapper {
|
|
|
204
256
|
|
|
205
257
|
private mapCsvRowToVariables(
|
|
206
258
|
row: CsvRow,
|
|
207
|
-
mapping: Record<string, string
|
|
259
|
+
mapping: Record<string, string>,
|
|
260
|
+
variableTypes: Record<string, string>
|
|
208
261
|
): Record<string, any> {
|
|
209
262
|
const variables: Record<string, any> = {};
|
|
210
263
|
|
|
211
264
|
for (const [graphqlVar, csvColumn] of Object.entries(mapping)) {
|
|
212
265
|
if (row[csvColumn] !== undefined) {
|
|
213
|
-
|
|
266
|
+
const rawValue = row[csvColumn];
|
|
267
|
+
const type = variableTypes[graphqlVar];
|
|
268
|
+
variables[graphqlVar] = this.convertValue(rawValue, type, graphqlVar);
|
|
214
269
|
}
|
|
215
270
|
}
|
|
216
271
|
|
|
217
272
|
return variables;
|
|
218
273
|
}
|
|
219
274
|
|
|
275
|
+
private extractVariableTypes(mutation: string): Record<string, string> {
|
|
276
|
+
const types: Record<string, string> = {};
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const document: DocumentNode = parse(mutation);
|
|
280
|
+
|
|
281
|
+
// Find the operation (mutation/query) and extract variable definitions
|
|
282
|
+
for (const definition of document.definitions) {
|
|
283
|
+
if (
|
|
284
|
+
definition.kind === "OperationDefinition" &&
|
|
285
|
+
definition.variableDefinitions
|
|
286
|
+
) {
|
|
287
|
+
for (const variableDef of definition.variableDefinitions) {
|
|
288
|
+
const varName = variableDef.variable.name.value;
|
|
289
|
+
const typeName = this.extractTypeName(variableDef);
|
|
290
|
+
if (typeName) {
|
|
291
|
+
types[varName] = typeName;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch (error) {
|
|
297
|
+
console.error("Error parsing GraphQL mutation:", error);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return types;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private extractTypeName(variableDef: VariableDefinitionNode): string | null {
|
|
304
|
+
const type = variableDef.type;
|
|
305
|
+
|
|
306
|
+
if (type.kind === "NonNullType") {
|
|
307
|
+
// Handle non-null types like String!
|
|
308
|
+
if (type.type.kind === "NamedType") {
|
|
309
|
+
return type.type.name.value;
|
|
310
|
+
}
|
|
311
|
+
} else if (type.kind === "NamedType") {
|
|
312
|
+
// Handle nullable types like String
|
|
313
|
+
return type.name.value;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private convertValue(
|
|
320
|
+
value: string,
|
|
321
|
+
type: string | undefined,
|
|
322
|
+
varName: string
|
|
323
|
+
): any {
|
|
324
|
+
if (!type) {
|
|
325
|
+
// No type information available, keep as string
|
|
326
|
+
return value;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const trimmedValue = value.trim();
|
|
330
|
+
|
|
331
|
+
switch (type) {
|
|
332
|
+
case "Int":
|
|
333
|
+
const intValue = Number(trimmedValue);
|
|
334
|
+
// Validate that it's a valid integer (no decimals, NaN, or Infinity)
|
|
335
|
+
if (
|
|
336
|
+
isNaN(intValue) ||
|
|
337
|
+
!isFinite(intValue) ||
|
|
338
|
+
!Number.isInteger(intValue)
|
|
339
|
+
) {
|
|
340
|
+
console.warn(
|
|
341
|
+
`Warning: Cannot convert "${value}" to Int for variable $${varName}. Expected a valid integer. Using original value.`
|
|
342
|
+
);
|
|
343
|
+
return value;
|
|
344
|
+
}
|
|
345
|
+
return intValue;
|
|
346
|
+
|
|
347
|
+
case "Float":
|
|
348
|
+
const floatValue = Number(trimmedValue);
|
|
349
|
+
// Number() is more strict than parseFloat() - it requires the entire string to be valid
|
|
350
|
+
if (isNaN(floatValue) || !isFinite(floatValue)) {
|
|
351
|
+
console.warn(
|
|
352
|
+
`Warning: Cannot convert "${value}" to Float for variable $${varName}. Expected a valid number. Using original value.`
|
|
353
|
+
);
|
|
354
|
+
return value;
|
|
355
|
+
}
|
|
356
|
+
return floatValue;
|
|
357
|
+
|
|
358
|
+
case "Boolean":
|
|
359
|
+
const lowerValue = trimmedValue.toLowerCase();
|
|
360
|
+
if (lowerValue === "true" || lowerValue === "1") return true;
|
|
361
|
+
if (lowerValue === "false" || lowerValue === "0") return false;
|
|
362
|
+
console.warn(
|
|
363
|
+
`Warning: Cannot convert "${value}" to Boolean for variable $${varName}. Expected "true", "false", "1", or "0". Using original value.`
|
|
364
|
+
);
|
|
365
|
+
return value;
|
|
366
|
+
|
|
367
|
+
case "String":
|
|
368
|
+
return value;
|
|
369
|
+
|
|
370
|
+
default:
|
|
371
|
+
// Unknown scalar type - keep as string for safety
|
|
372
|
+
if (this.verbose) {
|
|
373
|
+
console.log(
|
|
374
|
+
`Unknown GraphQL type "${type}" for variable $${varName}. Keeping value as string.`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
return value;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
220
381
|
getMetrics(): MetricsCollector {
|
|
221
382
|
return this.metrics;
|
|
222
383
|
}
|
package/src/metrics.ts
CHANGED
|
@@ -119,35 +119,43 @@ export class MetricsCollector {
|
|
|
119
119
|
const duration = this.getDurationMs();
|
|
120
120
|
const successRate = this.getSuccessRate();
|
|
121
121
|
const avgRequestDuration = this.getAverageRequestDuration();
|
|
122
|
-
|
|
122
|
+
|
|
123
123
|
let summary = `\nš Processing Summary:\n`;
|
|
124
124
|
summary += ` Total Processed: ${this.metrics.totalEntities}\n`;
|
|
125
125
|
summary += ` ā Successes: ${this.metrics.totalSuccesses}\n`;
|
|
126
126
|
summary += ` ā Failures: ${this.metrics.totalFailures}\n`;
|
|
127
127
|
summary += ` Success Rate: ${successRate.toFixed(1)}%\n`;
|
|
128
128
|
summary += ` Duration: ${(duration / 1000).toFixed(2)}s\n`;
|
|
129
|
-
|
|
129
|
+
|
|
130
130
|
if (this.metrics.requestDurations.length > 0) {
|
|
131
131
|
summary += ` Avg Request Time: ${avgRequestDuration.toFixed(0)}ms\n`;
|
|
132
132
|
}
|
|
133
|
-
|
|
133
|
+
|
|
134
134
|
if (this.metrics.retryAttempts > 0) {
|
|
135
135
|
summary += ` Retry Attempts: ${this.metrics.retryAttempts}\n`;
|
|
136
136
|
summary += ` Retry Successes: ${this.metrics.retrySuccesses}\n`;
|
|
137
137
|
summary += ` Retry Failures: ${this.metrics.retryFailures}\n`;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
if (this.metrics.entityMetrics.size >
|
|
140
|
+
if (this.metrics.entityMetrics.size > 0) {
|
|
141
141
|
summary += `\nš Per-Entity Breakdown:\n`;
|
|
142
142
|
for (const [entityName, entityMetric] of this.metrics.entityMetrics) {
|
|
143
|
-
const entityTotal =
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
143
|
+
const entityTotal =
|
|
144
|
+
entityMetric.successCount + entityMetric.failureCount;
|
|
145
|
+
const entityRate =
|
|
146
|
+
entityTotal > 0 ? (entityMetric.successCount / entityTotal) * 100 : 0;
|
|
147
|
+
const entityDuration = entityMetric.endTime
|
|
148
|
+
? entityMetric.endTime - entityMetric.startTime
|
|
149
|
+
: 0;
|
|
150
|
+
|
|
151
|
+
summary += ` ${entityName}: ${entityTotal} total (${
|
|
152
|
+
entityMetric.successCount
|
|
153
|
+
} ā, ${entityMetric.failureCount} ā) - ${entityRate.toFixed(
|
|
154
|
+
1
|
|
155
|
+
)}% success - ${(entityDuration / 1000).toFixed(2)}s\n`;
|
|
148
156
|
}
|
|
149
157
|
}
|
|
150
158
|
|
|
151
159
|
return summary;
|
|
152
160
|
}
|
|
153
|
-
}
|
|
161
|
+
}
|