@jackchuka/gql-ingest 1.0.2 → 1.2.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.
@@ -0,0 +1,98 @@
1
+ export interface DependencyGraph {
2
+ [entityName: string]: string[];
3
+ }
4
+
5
+ export interface ExecutionWave {
6
+ entities: string[];
7
+ wave: number;
8
+ }
9
+
10
+ export class DependencyResolver {
11
+ private dependencies: DependencyGraph;
12
+ private entities: string[];
13
+
14
+ constructor(entities: string[], dependencies: DependencyGraph = {}) {
15
+ this.entities = entities;
16
+ this.dependencies = dependencies;
17
+ }
18
+
19
+ resolveExecutionOrder(): ExecutionWave[] {
20
+ const waves: ExecutionWave[] = [];
21
+ const processed = new Set<string>();
22
+ const inProgress = new Set<string>();
23
+ let waveNumber = 0;
24
+
25
+ while (processed.size < this.entities.length) {
26
+ const currentWave: string[] = [];
27
+
28
+ for (const entity of this.entities) {
29
+ if (processed.has(entity) || inProgress.has(entity)) {
30
+ continue;
31
+ }
32
+
33
+ const deps = this.dependencies[entity] || [];
34
+ const canProcess = deps.every((dep) => processed.has(dep));
35
+
36
+ if (canProcess) {
37
+ currentWave.push(entity);
38
+ inProgress.add(entity);
39
+ }
40
+ }
41
+
42
+ if (currentWave.length === 0) {
43
+ const remaining = this.entities.filter((e) => !processed.has(e));
44
+ throw new Error(
45
+ `Circular dependency detected or missing dependencies for entities: ${remaining.join(
46
+ ", "
47
+ )}`
48
+ );
49
+ }
50
+
51
+ waves.push({
52
+ entities: currentWave,
53
+ wave: waveNumber++,
54
+ });
55
+
56
+ currentWave.forEach((entity) => {
57
+ processed.add(entity);
58
+ inProgress.delete(entity);
59
+ });
60
+ }
61
+
62
+ return waves;
63
+ }
64
+
65
+ validateDependencies(): string[] {
66
+ const errors: string[] = [];
67
+ const entitySet = new Set(this.entities);
68
+
69
+ for (const [entity, deps] of Object.entries(this.dependencies)) {
70
+ if (!entitySet.has(entity)) {
71
+ errors.push(
72
+ `Entity '${entity}' has dependencies but is not in the entity list`
73
+ );
74
+ continue;
75
+ }
76
+
77
+ for (const dep of deps) {
78
+ if (!entitySet.has(dep)) {
79
+ errors.push(
80
+ `Entity '${entity}' depends on '${dep}' which does not exist`
81
+ );
82
+ }
83
+ }
84
+ }
85
+
86
+ return errors;
87
+ }
88
+
89
+ getDependents(entityName: string): string[] {
90
+ return Object.entries(this.dependencies)
91
+ .filter(([_, deps]) => deps.includes(entityName))
92
+ .map(([entity, _]) => entity);
93
+ }
94
+
95
+ getDependencies(entityName: string): string[] {
96
+ return this.dependencies[entityName] || [];
97
+ }
98
+ }
@@ -1,20 +1,46 @@
1
1
  import { GraphQLClient } from 'graphql-request';
2
+ import { MetricsCollector } from './metrics';
2
3
 
3
4
  export class GraphQLClientWrapper {
4
5
  private client: GraphQLClient;
6
+ private metrics?: MetricsCollector;
7
+ private verbose: boolean;
5
8
 
6
- constructor(endpoint: string, headers?: Record<string, string>) {
9
+ constructor(endpoint: string, headers?: Record<string, string>, metrics?: MetricsCollector, verbose: boolean = false) {
7
10
  this.client = new GraphQLClient(endpoint, {
8
11
  headers: headers || {}
9
12
  });
13
+ this.metrics = metrics;
14
+ this.verbose = verbose;
10
15
  }
11
16
 
12
17
  async executeMutation(mutation: string, variables: Record<string, any>): Promise<any> {
18
+ const startTime = Date.now();
19
+
13
20
  try {
14
21
  const result = await this.client.request(mutation, variables);
22
+
23
+ if (this.metrics) {
24
+ const duration = Date.now() - startTime;
25
+ this.metrics.recordRequestDuration(duration);
26
+ }
27
+
28
+ if (this.verbose) {
29
+ console.log(`✓ GraphQL request completed in ${Date.now() - startTime}ms:`, result);
30
+ }
31
+
15
32
  return result;
16
33
  } catch (error) {
17
- console.error('GraphQL mutation failed:', error);
34
+ if (this.metrics) {
35
+ const duration = Date.now() - startTime;
36
+ this.metrics.recordRequestDuration(duration);
37
+ }
38
+
39
+ if (this.verbose) {
40
+ console.error(`✗ GraphQL request failed in ${Date.now() - startTime}ms:`, error);
41
+ } else {
42
+ console.error('GraphQL mutation failed:', error);
43
+ }
18
44
  throw error;
19
45
  }
20
46
  }
@@ -2,6 +2,7 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import { DataMapper } from "./mapper";
4
4
  import { GraphQLClientWrapper } from "./graphql-client";
5
+ import { MetricsCollector } from "./metrics";
5
6
 
6
7
  jest.mock("fs");
7
8
  jest.mock("./csv-reader");
@@ -10,6 +11,7 @@ const mockFs = fs as jest.Mocked<typeof fs>;
10
11
 
11
12
  describe("DataMapper", () => {
12
13
  let mockClient: jest.Mocked<GraphQLClientWrapper>;
14
+ let mockMetrics: jest.Mocked<MetricsCollector>;
13
15
  let dataMapper: DataMapper;
14
16
  const testBasePath = "/test/base/path";
15
17
 
@@ -19,7 +21,15 @@ describe("DataMapper", () => {
19
21
  setHeaders: jest.fn(),
20
22
  } as any;
21
23
 
22
- dataMapper = new DataMapper(mockClient, testBasePath);
24
+ mockMetrics = {
25
+ startEntityProcessing: jest.fn(),
26
+ recordSuccess: jest.fn(),
27
+ recordFailure: jest.fn(),
28
+ finishEntityProcessing: jest.fn(),
29
+ getMetrics: jest.fn(),
30
+ } as any;
31
+
32
+ dataMapper = new DataMapper(mockClient, testBasePath, mockMetrics);
23
33
  });
24
34
 
25
35
  afterEach(() => {
@@ -165,7 +175,7 @@ describe("DataMapper", () => {
165
175
  await dataMapper.processEntity("configs/test/mappings/users.json");
166
176
 
167
177
  expect(consoleSpy).toHaveBeenCalledWith(
168
- "✗ Failed to create entity for row:",
178
+ "✗ Failed to create entity for row 1:",
169
179
  { user_name: "John" },
170
180
  expect.any(Error)
171
181
  );
@@ -252,5 +262,74 @@ describe("DataMapper", () => {
252
262
  email: "john@example.com",
253
263
  });
254
264
  });
265
+
266
+ it("should call metrics methods during successful processing", async () => {
267
+ const mockConfig = {
268
+ csvFile: "data/users.csv",
269
+ graphqlFile: "graphql/users.graphql",
270
+ mapping: { name: "user_name" },
271
+ };
272
+
273
+ const mockCsvData = [{ user_name: "John" }, { user_name: "Jane" }];
274
+ const mockMutation =
275
+ "mutation CreateUser($name: String!) { createUser(input: { name: $name }) { id } }";
276
+
277
+ mockFs.readFileSync
278
+ .mockReturnValueOnce(JSON.stringify(mockConfig))
279
+ .mockReturnValueOnce(mockMutation);
280
+
281
+ const { readCsvFile } = require("./csv-reader");
282
+ readCsvFile.mockResolvedValue(mockCsvData);
283
+
284
+ mockClient.executeMutation.mockResolvedValue({
285
+ createUser: { id: "123" },
286
+ });
287
+
288
+ await dataMapper.processEntity("configs/test/mappings/users.json");
289
+
290
+ expect(mockMetrics.startEntityProcessing).toHaveBeenCalledWith("users");
291
+ expect(mockMetrics.recordSuccess).toHaveBeenCalledTimes(2);
292
+ expect(mockMetrics.recordSuccess).toHaveBeenCalledWith("users");
293
+ expect(mockMetrics.finishEntityProcessing).toHaveBeenCalledWith("users");
294
+ expect(mockMetrics.recordFailure).not.toHaveBeenCalled();
295
+ });
296
+
297
+ it("should call metrics methods during failed processing", async () => {
298
+ const mockConfig = {
299
+ csvFile: "data/users.csv",
300
+ graphqlFile: "graphql/users.graphql",
301
+ mapping: { name: "user_name" },
302
+ };
303
+
304
+ const mockCsvData = [{ user_name: "John" }];
305
+ const mockMutation =
306
+ "mutation CreateUser($name: String!) { createUser(input: { name: $name }) { id } }";
307
+
308
+ mockFs.readFileSync
309
+ .mockReturnValueOnce(JSON.stringify(mockConfig))
310
+ .mockReturnValueOnce(mockMutation);
311
+
312
+ const { readCsvFile } = require("./csv-reader");
313
+ readCsvFile.mockResolvedValue(mockCsvData);
314
+
315
+ mockClient.executeMutation.mockRejectedValue(new Error("GraphQL error"));
316
+
317
+ const consoleSpy = jest.spyOn(console, "error").mockImplementation();
318
+
319
+ await dataMapper.processEntity("configs/test/mappings/users.json");
320
+
321
+ expect(mockMetrics.startEntityProcessing).toHaveBeenCalledWith("users");
322
+ expect(mockMetrics.recordFailure).toHaveBeenCalledTimes(1);
323
+ expect(mockMetrics.recordFailure).toHaveBeenCalledWith("users");
324
+ expect(mockMetrics.finishEntityProcessing).toHaveBeenCalledWith("users");
325
+ expect(mockMetrics.recordSuccess).not.toHaveBeenCalled();
326
+
327
+ consoleSpy.mockRestore();
328
+ });
329
+
330
+ it("should expose metrics through getMetrics method", () => {
331
+ const metrics = dataMapper.getMetrics();
332
+ expect(metrics).toBe(mockMetrics);
333
+ });
255
334
  });
256
335
  });
package/src/mapper.ts CHANGED
@@ -1,7 +1,9 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import { readCsvFile, CsvRow } from './csv-reader';
4
- import { GraphQLClientWrapper } from './graphql-client';
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { readCsvFile, CsvRow } from "./csv-reader";
4
+ import { GraphQLClientWrapper } from "./graphql-client";
5
+ import { MetricsCollector } from "./metrics";
6
+ import { ParallelProcessingConfig } from "./config";
5
7
 
6
8
  export interface MappingConfig {
7
9
  csvFile: string;
@@ -12,69 +14,205 @@ export interface MappingConfig {
12
14
  export class DataMapper {
13
15
  private client: GraphQLClientWrapper;
14
16
  private basePath: string;
17
+ private metrics: MetricsCollector;
18
+ private verbose: boolean;
15
19
 
16
- constructor(client: GraphQLClientWrapper, basePath: string = process.cwd()) {
20
+ constructor(
21
+ client: GraphQLClientWrapper,
22
+ basePath: string = process.cwd(),
23
+ metrics?: MetricsCollector,
24
+ verbose: boolean = false
25
+ ) {
17
26
  this.client = client;
18
27
  this.basePath = basePath;
28
+ this.metrics = metrics || new MetricsCollector();
29
+ this.verbose = verbose;
19
30
  }
20
31
 
21
32
  discoverMappings(configDir: string): string[] {
22
- const mappingsPath = path.resolve(this.basePath, configDir, 'mappings');
23
-
33
+ const mappingsPath = path.resolve(this.basePath, configDir, "mappings");
34
+
24
35
  try {
25
36
  const files = fs.readdirSync(mappingsPath);
26
- const jsonFiles = files
27
- .filter(file => file.endsWith('.json'))
28
- .sort(); // Alphabetical order for consistent processing
29
-
30
- console.log(`Discovered ${jsonFiles.length} mapping files: ${jsonFiles.join(', ')}`);
31
- return jsonFiles.map(file => path.join(configDir, 'mappings', file));
37
+ const jsonFiles = files.filter((file) => file.endsWith(".json")).sort(); // Alphabetical order for consistent processing
38
+
39
+ console.log(
40
+ `Discovered ${jsonFiles.length} mapping files: ${jsonFiles.join(", ")}`
41
+ );
42
+ return jsonFiles.map((file) => path.join(configDir, "mappings", file));
32
43
  } catch (error) {
33
44
  console.error(`Error reading mappings directory ${mappingsPath}:`, error);
34
45
  return [];
35
46
  }
36
47
  }
37
48
 
38
- async processEntity(configPath: string): Promise<void> {
49
+ async processEntity(
50
+ configPath: string,
51
+ parallelConfig?: ParallelProcessingConfig
52
+ ): Promise<void> {
53
+ const entityName = path.basename(configPath, ".json");
39
54
  console.log(`Processing entity: ${configPath}`);
40
-
55
+
56
+ this.metrics.startEntityProcessing(entityName);
57
+
41
58
  // Read mapping configuration
42
59
  const configFullPath = path.resolve(this.basePath, configPath);
43
- const config: MappingConfig = JSON.parse(fs.readFileSync(configFullPath, 'utf8'));
44
-
60
+ const config: MappingConfig = JSON.parse(
61
+ fs.readFileSync(configFullPath, "utf8")
62
+ );
63
+
45
64
  // Extract config directory (parent of mappings directory)
46
65
  const configDir = path.dirname(path.dirname(configFullPath));
47
-
66
+
48
67
  // Read CSV data (relative to config directory)
49
68
  const csvPath = path.resolve(configDir, config.csvFile);
50
69
  const csvData = await readCsvFile(csvPath);
51
-
70
+
52
71
  // Read GraphQL mutation (relative to config directory)
53
72
  const graphqlPath = path.resolve(configDir, config.graphqlFile);
54
- const mutation = fs.readFileSync(graphqlPath, 'utf8');
73
+ const mutation = fs.readFileSync(graphqlPath, "utf8");
74
+
75
+ // Process rows with optional parallelization
76
+ if (parallelConfig && parallelConfig.concurrency > 1) {
77
+ await this.processRowsConcurrently(
78
+ csvData,
79
+ mutation,
80
+ config.mapping,
81
+ entityName,
82
+ parallelConfig
83
+ );
84
+ } else {
85
+ await this.processRowsSequentially(
86
+ csvData,
87
+ mutation,
88
+ config.mapping,
89
+ entityName
90
+ );
91
+ }
92
+
93
+ this.metrics.finishEntityProcessing(entityName);
94
+ }
95
+
96
+ private async processRowsSequentially(
97
+ csvData: CsvRow[],
98
+ mutation: string,
99
+ mapping: Record<string, string>,
100
+ entityName: string
101
+ ): Promise<void> {
102
+ const totalRows = csvData.length;
55
103
 
56
- // Process each row
57
- for (const row of csvData) {
58
- const variables = this.mapCsvRowToVariables(row, config.mapping);
59
-
104
+ for (let i = 0; i < csvData.length; i++) {
105
+ const row = csvData[i];
106
+ const variables = this.mapCsvRowToVariables(row, mapping);
107
+
60
108
  try {
61
- const result = await this.client.executeMutation(mutation, variables);
62
- console.log(`✓ Created entity with result:`, result);
109
+ await this.client.executeMutation(mutation, variables);
110
+ this.metrics.recordSuccess(entityName);
111
+
112
+ // Show progress every 10% or at the end (only in non-verbose mode)
113
+ if (!this.verbose && ((i + 1) % Math.max(1, Math.floor(totalRows / 10)) === 0 || i === totalRows - 1)) {
114
+ const progress = (((i + 1) / totalRows) * 100).toFixed(1);
115
+ console.log(`📊 Progress: ${i + 1}/${totalRows} (${progress}%) ✓`);
116
+ }
63
117
  } catch (error) {
64
- console.error(`✗ Failed to create entity for row:`, row, error);
118
+ this.metrics.recordFailure(entityName);
119
+ if (!this.verbose) {
120
+ console.error(`✗ Failed to create entity for row ${i + 1}:`, row, error);
121
+ }
65
122
  }
66
123
  }
67
124
  }
68
125
 
69
- private mapCsvRowToVariables(row: CsvRow, mapping: Record<string, string>): Record<string, any> {
126
+ private async processRowsConcurrently(
127
+ csvData: CsvRow[],
128
+ mutation: string,
129
+ mapping: Record<string, string>,
130
+ entityName: string,
131
+ parallelConfig: ParallelProcessingConfig
132
+ ): Promise<void> {
133
+ const concurrency = parallelConfig.concurrency;
134
+ console.log(
135
+ `Processing ${csvData.length} rows with concurrency: ${concurrency}`
136
+ );
137
+
138
+ // Split data into chunks for concurrent processing
139
+ const chunks = this.chunkArray(csvData, concurrency);
140
+ let processedCount = 0;
141
+ const totalRows = csvData.length;
142
+
143
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
144
+ const chunk = chunks[chunkIndex];
145
+ const promises = chunk.map(async (row) => {
146
+ const variables = this.mapCsvRowToVariables(row, mapping);
147
+
148
+ try {
149
+ const result = await this.client.executeMutation(mutation, variables);
150
+ this.metrics.recordSuccess(entityName);
151
+ return { success: true, result, row };
152
+ } catch (error) {
153
+ this.metrics.recordFailure(entityName);
154
+ return { success: false, error, row };
155
+ }
156
+ });
157
+
158
+ const results = await Promise.allSettled(promises);
159
+ processedCount += chunk.length;
160
+
161
+ // Count successes and failures in this chunk
162
+ let chunkSuccesses = 0;
163
+ let chunkFailures = 0;
164
+
165
+ results.forEach((result) => {
166
+ if (result.status === "fulfilled") {
167
+ const { success, error, row } = result.value;
168
+ if (success) {
169
+ chunkSuccesses++;
170
+ } else {
171
+ chunkFailures++;
172
+ if (!this.verbose) {
173
+ console.error(`✗ Failed to create entity for row:`, row, error);
174
+ }
175
+ }
176
+ } else {
177
+ chunkFailures++;
178
+ if (!this.verbose) {
179
+ console.error(`✗ Promise rejected:`, result.reason);
180
+ }
181
+ }
182
+ });
183
+
184
+ // Show progress update (only in non-verbose mode)
185
+ if (!this.verbose) {
186
+ const progress = ((processedCount / totalRows) * 100).toFixed(1);
187
+ console.log(`📊 Progress: ${processedCount}/${totalRows} (${progress}%) - Chunk ${chunkIndex + 1}: ${chunkSuccesses} ✓, ${chunkFailures} ✗`);
188
+ }
189
+ }
190
+ }
191
+
192
+ private chunkArray<T>(array: T[], chunkSize: number): T[][] {
193
+ const chunks: T[][] = [];
194
+ for (let i = 0; i < array.length; i += chunkSize) {
195
+ chunks.push(array.slice(i, i + chunkSize));
196
+ }
197
+ return chunks;
198
+ }
199
+
200
+ private mapCsvRowToVariables(
201
+ row: CsvRow,
202
+ mapping: Record<string, string>
203
+ ): Record<string, any> {
70
204
  const variables: Record<string, any> = {};
71
-
205
+
72
206
  for (const [graphqlVar, csvColumn] of Object.entries(mapping)) {
73
207
  if (row[csvColumn] !== undefined) {
74
208
  variables[graphqlVar] = row[csvColumn];
75
209
  }
76
210
  }
77
-
211
+
78
212
  return variables;
79
213
  }
80
- }
214
+
215
+ getMetrics(): MetricsCollector {
216
+ return this.metrics;
217
+ }
218
+ }