@jackchuka/gql-ingest 1.4.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.
@@ -8,7 +8,8 @@ export interface ExecutionWave {
8
8
  export declare class DependencyResolver {
9
9
  private dependencies;
10
10
  private entities;
11
- constructor(entities: string[], dependencies?: DependencyGraph);
11
+ private allowPartialResolution;
12
+ constructor(entities: string[], dependencies?: DependencyGraph, allowPartialResolution?: boolean);
12
13
  resolveExecutionOrder(): ExecutionWave[];
13
14
  validateDependencies(): string[];
14
15
  getDependents(entityName: string): string[];
@@ -1 +1 @@
1
- {"version":3,"file":"dependency-resolver.d.ts","sourceRoot":"","sources":["../src/dependency-resolver.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,eAAe;IAC9B,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,YAAY,CAAkB;IACtC,OAAO,CAAC,QAAQ,CAAW;gBAEf,QAAQ,EAAE,MAAM,EAAE,EAAE,YAAY,GAAE,eAAoB;IAKlE,qBAAqB,IAAI,aAAa,EAAE;IA8CxC,oBAAoB,IAAI,MAAM,EAAE;IAwBhC,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE;IAM3C,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE;CAG9C"}
1
+ {"version":3,"file":"dependency-resolver.d.ts","sourceRoot":"","sources":["../src/dependency-resolver.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,eAAe;IAC9B,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,YAAY,CAAkB;IACtC,OAAO,CAAC,QAAQ,CAAW;IAC3B,OAAO,CAAC,sBAAsB,CAAU;gBAE5B,QAAQ,EAAE,MAAM,EAAE,EAAE,YAAY,GAAE,eAAoB,EAAE,sBAAsB,GAAE,OAAe;IAM3G,qBAAqB,IAAI,aAAa,EAAE;IAgDxC,oBAAoB,IAAI,MAAM,EAAE;IAwBhC,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE;IAM3C,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE;CAG9C"}
package/dist/mapper.d.ts CHANGED
@@ -12,7 +12,7 @@ export declare class DataMapper {
12
12
  private metrics;
13
13
  private verbose;
14
14
  constructor(client: GraphQLClientWrapper, basePath?: string, metrics?: MetricsCollector, verbose?: boolean);
15
- discoverMappings(configDir: string): string[];
15
+ discoverMappings(configDir: string, entityFilter?: string[]): string[];
16
16
  processEntity(configPath: string, parallelConfig?: ParallelProcessingConfig, retryConfig?: RetryConfig): Promise<void>;
17
17
  private processRowsSequentially;
18
18
  private processRowsConcurrently;
@@ -1 +1 @@
1
- {"version":3,"file":"mapper.d.ts","sourceRoot":"","sources":["../src/mapper.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,wBAAwB,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAEjE,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,qBAAa,UAAU;IACrB,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,OAAO,CAAmB;IAClC,OAAO,CAAC,OAAO,CAAU;gBAGvB,MAAM,EAAE,oBAAoB,EAC5B,QAAQ,GAAE,MAAsB,EAChC,OAAO,CAAC,EAAE,gBAAgB,EAC1B,OAAO,GAAE,OAAe;IAQ1B,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE;IAiBvC,aAAa,CACjB,UAAU,EAAE,MAAM,EAClB,cAAc,CAAC,EAAE,wBAAwB,EACzC,WAAW,CAAC,EAAE,WAAW,GACxB,OAAO,CAAC,IAAI,CAAC;YA8CF,uBAAuB;YAwCvB,uBAAuB;IA8ErC,OAAO,CAAC,UAAU;IAQlB,OAAO,CAAC,oBAAoB;IAkB5B,OAAO,CAAC,oBAAoB;IA4B5B,OAAO,CAAC,eAAe;IAgBvB,OAAO,CAAC,YAAY;IAsDpB,UAAU,IAAI,gBAAgB;CAG/B"}
1
+ {"version":3,"file":"mapper.d.ts","sourceRoot":"","sources":["../src/mapper.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,wBAAwB,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAEjE,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,qBAAa,UAAU;IACrB,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,OAAO,CAAmB;IAClC,OAAO,CAAC,OAAO,CAAU;gBAGvB,MAAM,EAAE,oBAAoB,EAC5B,QAAQ,GAAE,MAAsB,EAChC,OAAO,CAAC,EAAE,gBAAgB,EAC1B,OAAO,GAAE,OAAe;IAQ1B,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE;IA4ChE,aAAa,CACjB,UAAU,EAAE,MAAM,EAClB,cAAc,CAAC,EAAE,wBAAwB,EACzC,WAAW,CAAC,EAAE,WAAW,GACxB,OAAO,CAAC,IAAI,CAAC;YA8CF,uBAAuB;YAwCvB,uBAAuB;IAkFrC,OAAO,CAAC,UAAU;IAQlB,OAAO,CAAC,oBAAoB;IAkB5B,OAAO,CAAC,oBAAoB;IA4B5B,OAAO,CAAC,eAAe;IAgBvB,OAAO,CAAC,YAAY;IA8DpB,UAAU,IAAI,gBAAgB;CAG/B"}
@@ -1 +1 @@
1
- {"version":3,"file":"metrics.d.ts","sourceRoot":"","sources":["../src/metrics.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,iBAAiB;IAChC,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IAC1C,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,OAAO,CAAoB;;IAgBnC,qBAAqB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAW/C,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IASvC,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IASvC,sBAAsB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAOhD,gBAAgB,IAAI,iBAAiB;IAKrC,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS;IAI/D,iBAAiB,IAAI,MAAM;IAI3B,cAAc,IAAI,MAAM;IAKxB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAI7C,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAK1C,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAK1C,yBAAyB,IAAI,MAAM;IAMnC,aAAa,IAAI,MAAM;IAKvB,eAAe,IAAI,MAAM;CAmC1B"}
1
+ {"version":3,"file":"metrics.d.ts","sourceRoot":"","sources":["../src/metrics.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,iBAAiB;IAChC,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IAC1C,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,OAAO,CAAoB;;IAgBnC,qBAAqB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAW/C,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IASvC,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IASvC,sBAAsB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAOhD,gBAAgB,IAAI,iBAAiB;IAKrC,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS;IAI/D,iBAAiB,IAAI,MAAM;IAI3B,cAAc,IAAI,MAAM;IAKxB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAI7C,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAK1C,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAK1C,yBAAyB,IAAI,MAAM;IAMnC,aAAa,IAAI,MAAM;IAKvB,eAAe,IAAI,MAAM;CA2C1B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackchuka/gql-ingest",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "A CLI tool for ingesting data from CSV files into a GraphQL API",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
package/src/cli.ts CHANGED
@@ -31,6 +31,10 @@ program
31
31
  "-c, --config <path>",
32
32
  "Path to configuration directory (containing data/, graphql/, mappings/ subdirectories)"
33
33
  )
34
+ .option(
35
+ "-n, --entities <entities>",
36
+ "Comma-separated list of specific entities to process (e.g., users,products)"
37
+ )
34
38
  .option(
35
39
  "-h, --headers <headers>",
36
40
  "JSON string of headers to include in requests"
@@ -65,29 +69,61 @@ program
65
69
  options.verbose
66
70
  );
67
71
 
72
+ // Parse entities filter if provided
73
+ const entityFilter = options.entities
74
+ ? options.entities.split(",").map((e: string) => e.trim())
75
+ : undefined;
76
+
68
77
  // Discover all mapping files dynamically
69
- const mappingPaths = mapper.discoverMappings(options.config);
78
+ const mappingPaths = mapper.discoverMappings(
79
+ options.config,
80
+ entityFilter
81
+ );
70
82
 
71
83
  if (mappingPaths.length === 0) {
72
- console.warn(`No mapping files found in ${options.config}/mappings`);
84
+ const filterMsg = entityFilter
85
+ ? ` matching entities: ${entityFilter.join(", ")}`
86
+ : "";
87
+ console.warn(
88
+ `No mapping files found in ${options.config}/mappings${filterMsg}`
89
+ );
73
90
  return;
74
91
  }
75
92
 
76
93
  // Extract entity names from mapping paths
77
94
  const entityNames = mappingPaths.map((path) => basename(path, ".json"));
78
95
 
79
- // Setup dependency resolver
96
+ // Filter dependencies to only include those relevant to selected entities
97
+ const relevantDependencies: Record<string, string[]> = {};
98
+ if (config.entityDependencies) {
99
+ for (const entity of entityNames) {
100
+ if (config.entityDependencies[entity]) {
101
+ relevantDependencies[entity] = config.entityDependencies[entity];
102
+ }
103
+ }
104
+ }
105
+
106
+ // Setup dependency resolver with filtered dependencies
80
107
  const resolver = new DependencyResolver(
81
108
  entityNames,
82
- config.entityDependencies
109
+ relevantDependencies,
110
+ !!entityFilter // Allow partial resolution when using --entities
83
111
  );
84
112
 
85
113
  // Validate dependencies
86
114
  const validationErrors = resolver.validateDependencies();
87
115
  if (validationErrors.length > 0) {
88
- console.error("Dependency validation errors:");
89
- validationErrors.forEach((error) => console.error(` - ${error}`));
90
- process.exit(1);
116
+ if (entityFilter) {
117
+ // When using --entities flag, show warnings instead of errors
118
+ console.warn("\nāš ļø Warning: Dependency validation issues:");
119
+ validationErrors.forEach((error) => console.warn(` - ${error}`));
120
+ console.warn("This may cause errors if the dependent data doesn't already exist.\n");
121
+ } else {
122
+ // Strict validation when processing all entities
123
+ console.error("Dependency validation errors:");
124
+ validationErrors.forEach((error) => console.error(` - ${error}`));
125
+ process.exit(1);
126
+ }
91
127
  }
92
128
 
93
129
  // Process entities in dependency-aware waves
@@ -88,12 +88,26 @@ describe("DependencyResolver", () => {
88
88
  a: ["missing"],
89
89
  b: ["a"],
90
90
  };
91
- const resolver = new DependencyResolver(entities, dependencies);
91
+ const resolver = new DependencyResolver(entities, dependencies, false);
92
92
 
93
93
  expect(() => resolver.resolveExecutionOrder()).toThrow(
94
94
  "Circular dependency detected or missing dependencies for entities: a, b"
95
95
  );
96
96
  });
97
+
98
+ it("should allow entities with dependencies not in the entity list when partial resolution is enabled", () => {
99
+ const entities = ["a", "b"];
100
+ const dependencies = {
101
+ a: ["missing"],
102
+ b: ["a"],
103
+ };
104
+ const resolver = new DependencyResolver(entities, dependencies, true);
105
+
106
+ const waves = resolver.resolveExecutionOrder();
107
+ expect(waves).toHaveLength(2);
108
+ expect(waves[0].entities).toEqual(["a"]);
109
+ expect(waves[1].entities).toEqual(["b"]);
110
+ });
97
111
  });
98
112
 
99
113
  describe("validateDependencies", () => {
@@ -10,10 +10,12 @@ export interface ExecutionWave {
10
10
  export class DependencyResolver {
11
11
  private dependencies: DependencyGraph;
12
12
  private entities: string[];
13
+ private allowPartialResolution: boolean;
13
14
 
14
- constructor(entities: string[], dependencies: DependencyGraph = {}) {
15
+ constructor(entities: string[], dependencies: DependencyGraph = {}, allowPartialResolution: boolean = false) {
15
16
  this.entities = entities;
16
17
  this.dependencies = dependencies;
18
+ this.allowPartialResolution = allowPartialResolution;
17
19
  }
18
20
 
19
21
  resolveExecutionOrder(): ExecutionWave[] {
@@ -31,7 +33,9 @@ export class DependencyResolver {
31
33
  }
32
34
 
33
35
  const deps = this.dependencies[entity] || [];
34
- const canProcess = deps.every((dep) => processed.has(dep));
36
+ const canProcess = deps.every((dep) =>
37
+ processed.has(dep) || (this.allowPartialResolution && !this.entities.includes(dep))
38
+ );
35
39
 
36
40
  if (canProcess) {
37
41
  currentWave.push(entity);
package/src/mapper.ts CHANGED
@@ -30,12 +30,39 @@ export class DataMapper {
30
30
  this.verbose = verbose;
31
31
  }
32
32
 
33
- discoverMappings(configDir: string): string[] {
33
+ discoverMappings(configDir: string, entityFilter?: string[]): string[] {
34
34
  const mappingsPath = path.resolve(this.basePath, configDir, "mappings");
35
35
 
36
36
  try {
37
37
  const files = fs.readdirSync(mappingsPath);
38
- const jsonFiles = files.filter((file) => file.endsWith(".json")).sort(); // Alphabetical order for consistent processing
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
39
66
 
40
67
  console.log(
41
68
  `Discovered ${jsonFiles.length} mapping files: ${jsonFiles.join(", ")}`
@@ -161,7 +188,11 @@ export class DataMapper {
161
188
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
162
189
  const chunk = chunks[chunkIndex];
163
190
  const promises = chunk.map(async (row) => {
164
- const variables = this.mapCsvRowToVariables(row, mapping, variableTypes);
191
+ const variables = this.mapCsvRowToVariables(
192
+ row,
193
+ mapping,
194
+ variableTypes
195
+ );
165
196
 
166
197
  try {
167
198
  const result = await this.client.executeMutation(
@@ -285,7 +316,11 @@ export class DataMapper {
285
316
  return null;
286
317
  }
287
318
 
288
- private convertValue(value: string, type: string | undefined, varName: string): any {
319
+ private convertValue(
320
+ value: string,
321
+ type: string | undefined,
322
+ varName: string
323
+ ): any {
289
324
  if (!type) {
290
325
  // No type information available, keep as string
291
326
  return value;
@@ -297,7 +332,11 @@ export class DataMapper {
297
332
  case "Int":
298
333
  const intValue = Number(trimmedValue);
299
334
  // Validate that it's a valid integer (no decimals, NaN, or Infinity)
300
- if (isNaN(intValue) || !isFinite(intValue) || !Number.isInteger(intValue)) {
335
+ if (
336
+ isNaN(intValue) ||
337
+ !isFinite(intValue) ||
338
+ !Number.isInteger(intValue)
339
+ ) {
301
340
  console.warn(
302
341
  `Warning: Cannot convert "${value}" to Int for variable $${varName}. Expected a valid integer. Using original value.`
303
342
  );
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 > 1) {
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 = entityMetric.successCount + entityMetric.failureCount;
144
- const entityRate = entityTotal > 0 ? (entityMetric.successCount / entityTotal) * 100 : 0;
145
- const entityDuration = entityMetric.endTime ? entityMetric.endTime - entityMetric.startTime : 0;
146
-
147
- summary += ` ${entityName}: ${entityTotal} total (${entityMetric.successCount} āœ“, ${entityMetric.failureCount} āœ—) - ${entityRate.toFixed(1)}% success - ${(entityDuration / 1000).toFixed(2)}s\n`;
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
+ }