@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.
package/src/cli.ts CHANGED
@@ -1,6 +1,20 @@
1
1
  import { Command } from "commander";
2
2
  import { GraphQLClientWrapper } from "./graphql-client";
3
3
  import { DataMapper } from "./mapper";
4
+ import { MetricsCollector } from "./metrics";
5
+ import { loadConfig, getEntityConfig } from "./config";
6
+ import { DependencyResolver } from "./dependency-resolver";
7
+ import { basename } from "path";
8
+
9
+ // Utility function to chunk array into smaller arrays
10
+ function chunkArray<T>(array: T[], chunkSize: number): T[][] {
11
+ if (chunkSize <= 0) return [array];
12
+ const chunks: T[][] = [];
13
+ for (let i = 0; i < array.length; i += chunkSize) {
14
+ chunks.push(array.slice(i, i + chunkSize));
15
+ }
16
+ return chunks;
17
+ }
4
18
 
5
19
  const program = new Command();
6
20
 
@@ -21,6 +35,7 @@ program
21
35
  "-h, --headers <headers>",
22
36
  "JSON string of headers to include in requests"
23
37
  )
38
+ .option("-v, --verbose", "Show detailed request results and responses")
24
39
  .action(async (options) => {
25
40
  try {
26
41
  console.log("Starting seed data generation...");
@@ -28,11 +43,27 @@ program
28
43
  // Parse headers if provided
29
44
  const headers = options.headers ? JSON.parse(options.headers) : {};
30
45
 
46
+ // Initialize metrics collector
47
+ const metrics = new MetricsCollector();
48
+
31
49
  // Initialize GraphQL client
32
- const client = new GraphQLClientWrapper(options.endpoint, headers);
50
+ const client = new GraphQLClientWrapper(
51
+ options.endpoint,
52
+ headers,
53
+ metrics,
54
+ options.verbose
55
+ );
56
+
57
+ // Load configuration
58
+ const config = loadConfig(options.config);
33
59
 
34
60
  // Initialize data mapper
35
- const mapper = new DataMapper(client);
61
+ const mapper = new DataMapper(
62
+ client,
63
+ process.cwd(),
64
+ metrics,
65
+ options.verbose
66
+ );
36
67
 
37
68
  // Discover all mapping files dynamically
38
69
  const mappingPaths = mapper.discoverMappings(options.config);
@@ -42,19 +73,92 @@ program
42
73
  return;
43
74
  }
44
75
 
45
- for (const configPath of mappingPaths) {
46
- try {
47
- await mapper.processEntity(configPath);
48
- } catch (error) {
49
- console.warn(`Warning: Could not process ${configPath}:`, error);
50
- }
76
+ // Extract entity names from mapping paths
77
+ const entityNames = mappingPaths.map((path) => basename(path, ".json"));
78
+
79
+ // Setup dependency resolver
80
+ const resolver = new DependencyResolver(
81
+ entityNames,
82
+ config.entityDependencies
83
+ );
84
+
85
+ // Validate dependencies
86
+ const validationErrors = resolver.validateDependencies();
87
+ if (validationErrors.length > 0) {
88
+ console.error("Dependency validation errors:");
89
+ validationErrors.forEach((error) => console.error(` - ${error}`));
90
+ process.exit(1);
91
+ }
92
+
93
+ // Process entities in dependency-aware waves
94
+ if (config.parallelProcessing.entityConcurrency === 1) {
95
+ await processEntitiesSequentially(mappingPaths, mapper, config);
96
+ } else {
97
+ await processEntitiesInWaves(mappingPaths, resolver, mapper, config);
51
98
  }
52
99
 
53
- console.log("Seed data generation completed!");
100
+ metrics.finishProcessing();
101
+ console.log(metrics.generateSummary());
54
102
  } catch (error) {
55
103
  console.error("Error:", error);
56
104
  process.exit(1);
57
105
  }
58
106
  });
59
107
 
108
+ async function processEntitiesSequentially(
109
+ mappingPaths: string[],
110
+ mapper: DataMapper,
111
+ config: ReturnType<typeof loadConfig>
112
+ ): Promise<void> {
113
+ for (const configPath of mappingPaths) {
114
+ try {
115
+ const entityName = basename(configPath, ".json");
116
+ const entityConfig = getEntityConfig(entityName, config);
117
+ await mapper.processEntity(configPath, entityConfig);
118
+ } catch (error) {
119
+ console.warn(`Warning: Could not process ${configPath}:`, error);
120
+ }
121
+ }
122
+ }
123
+
124
+ async function processEntitiesInWaves(
125
+ mappingPaths: string[],
126
+ resolver: DependencyResolver,
127
+ mapper: DataMapper,
128
+ config: ReturnType<typeof loadConfig>
129
+ ): Promise<void> {
130
+ const waves = resolver.resolveExecutionOrder();
131
+ const pathMap = new Map(
132
+ mappingPaths.map((path) => [basename(path, ".json"), path])
133
+ );
134
+
135
+ console.log(`Processing ${waves.length} dependency waves...`);
136
+
137
+ for (const wave of waves) {
138
+ console.log(
139
+ `Wave ${wave.wave + 1}: Processing entities [${wave.entities.join(", ")}]`
140
+ );
141
+
142
+ // Process entities in controlled batches based on entityConcurrency
143
+ const entityConcurrency = config.parallelProcessing.entityConcurrency;
144
+ const chunks = chunkArray(wave.entities, entityConcurrency);
145
+
146
+ for (const chunk of chunks) {
147
+ const entityPromises = chunk.map(async (entityName) => {
148
+ const configPath = pathMap.get(entityName);
149
+ if (configPath) {
150
+ try {
151
+ const entityConfig = getEntityConfig(entityName, config);
152
+ await mapper.processEntity(configPath, entityConfig);
153
+ } catch (error) {
154
+ console.warn(`Warning: Could not process ${configPath}:`, error);
155
+ }
156
+ }
157
+ });
158
+
159
+ await Promise.allSettled(entityPromises);
160
+ }
161
+ }
162
+ }
163
+
60
164
  program.parse();
@@ -0,0 +1,204 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { loadConfig, getEntityConfig, DEFAULT_CONFIG } from "./config";
4
+
5
+ jest.mock("fs");
6
+ const mockFs = fs as jest.Mocked<typeof fs>;
7
+
8
+ describe("Configuration", () => {
9
+ const testConfigDir = "/test/config";
10
+ const configPath = path.join(testConfigDir, "config.yaml");
11
+
12
+ afterEach(() => {
13
+ jest.clearAllMocks();
14
+ });
15
+
16
+ describe("loadConfig", () => {
17
+ it("should return default config when no config.yaml exists", () => {
18
+ mockFs.existsSync.mockReturnValue(false);
19
+ const consoleSpy = jest.spyOn(console, "log").mockImplementation();
20
+
21
+ const config = loadConfig(testConfigDir);
22
+
23
+ expect(config).toEqual(DEFAULT_CONFIG);
24
+ expect(consoleSpy).toHaveBeenCalledWith(
25
+ "No config.yaml found, using default sequential processing"
26
+ );
27
+
28
+ consoleSpy.mockRestore();
29
+ });
30
+
31
+ it("should load and merge YAML configuration", () => {
32
+ const yamlContent = `
33
+ parallelProcessing:
34
+ concurrency: 5
35
+ entityConcurrency: 3
36
+
37
+ entityConfig:
38
+ users:
39
+ concurrency: 2
40
+ preserveRowOrder: true
41
+
42
+ entityDependencies:
43
+ products: ["users"]
44
+ `;
45
+
46
+ mockFs.existsSync.mockReturnValue(true);
47
+ mockFs.readFileSync.mockReturnValue(yamlContent);
48
+
49
+ const config = loadConfig(testConfigDir);
50
+
51
+ expect(config.parallelProcessing.concurrency).toBe(5);
52
+ expect(config.parallelProcessing.entityConcurrency).toBe(3);
53
+ expect(config.entityConfig.users.concurrency).toBe(2);
54
+ expect(config.entityConfig.users.preserveRowOrder).toBe(true);
55
+ expect(config.entityDependencies.products).toEqual(["users"]);
56
+ });
57
+
58
+ it("should merge partial configuration with defaults", () => {
59
+ const yamlContent = `
60
+ parallelProcessing:
61
+ concurrency: 10
62
+ entityConfig:
63
+ products:
64
+ concurrency: 20
65
+ `;
66
+
67
+ mockFs.existsSync.mockReturnValue(true);
68
+ mockFs.readFileSync.mockReturnValue(yamlContent);
69
+
70
+ const config = loadConfig(testConfigDir);
71
+
72
+ // Should merge with defaults
73
+ expect(config.parallelProcessing.concurrency).toBe(10);
74
+ expect(config.parallelProcessing.entityConcurrency).toBe(1); // default
75
+ expect(config.parallelProcessing.preserveRowOrder).toBe(true); // default
76
+ });
77
+
78
+ it("should handle invalid YAML gracefully", () => {
79
+ mockFs.existsSync.mockReturnValue(true);
80
+ mockFs.readFileSync.mockReturnValue("invalid: yaml: content: [");
81
+
82
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
83
+
84
+ const config = loadConfig(testConfigDir);
85
+
86
+ expect(config).toEqual(DEFAULT_CONFIG);
87
+ expect(consoleSpy).toHaveBeenCalledWith(
88
+ expect.stringContaining("Warning: Failed to parse config.yaml")
89
+ );
90
+
91
+ consoleSpy.mockRestore();
92
+ });
93
+
94
+ it("should handle file read errors gracefully", () => {
95
+ mockFs.existsSync.mockReturnValue(true);
96
+ mockFs.readFileSync.mockImplementation(() => {
97
+ throw new Error("File read error");
98
+ });
99
+
100
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
101
+
102
+ const config = loadConfig(testConfigDir);
103
+
104
+ expect(config).toEqual(DEFAULT_CONFIG);
105
+ expect(consoleSpy).toHaveBeenCalledWith(
106
+ expect.stringContaining("Warning: Failed to parse config.yaml")
107
+ );
108
+
109
+ consoleSpy.mockRestore();
110
+ });
111
+ });
112
+
113
+ describe("getEntityConfig", () => {
114
+ const globalConfig = {
115
+ parallelProcessing: {
116
+ concurrency: 10,
117
+ entityConcurrency: 3,
118
+ preserveRowOrder: false,
119
+ },
120
+ entityConfig: {
121
+ users: {
122
+ concurrency: 2,
123
+ preserveRowOrder: true,
124
+ },
125
+ products: {
126
+ concurrency: 20,
127
+ },
128
+ },
129
+ entityDependencies: {},
130
+ };
131
+
132
+ it("should return global config for entity without overrides", () => {
133
+ const entityConfig = getEntityConfig("orders", globalConfig);
134
+
135
+ expect(entityConfig).toEqual(globalConfig.parallelProcessing);
136
+ });
137
+
138
+ it("should merge entity overrides with global config", () => {
139
+ const entityConfig = getEntityConfig("products", globalConfig);
140
+
141
+ expect(entityConfig.concurrency).toBe(20); // overridden
142
+ expect(entityConfig.entityConcurrency).toBe(3); // from global
143
+ expect(entityConfig.preserveRowOrder).toBe(false); // from global
144
+ });
145
+
146
+ it("should apply preserveRowOrder constraint", () => {
147
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
148
+
149
+ const entityConfig = getEntityConfig("users", globalConfig);
150
+
151
+ expect(entityConfig.concurrency).toBe(1); // forced to 1
152
+ expect(entityConfig.preserveRowOrder).toBe(true);
153
+ expect(consoleSpy).toHaveBeenCalledWith(
154
+ "Entity 'users': preserveRowOrder=true forces concurrency=1 (was 2)"
155
+ );
156
+
157
+ consoleSpy.mockRestore();
158
+ });
159
+
160
+ it("should not apply constraint when concurrency is already 1", () => {
161
+ const config = {
162
+ ...globalConfig,
163
+ entityConfig: {
164
+ ...globalConfig.entityConfig,
165
+ sequential: {
166
+ concurrency: 1,
167
+ preserveRowOrder: true,
168
+ },
169
+ },
170
+ };
171
+
172
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
173
+
174
+ const entityConfig = getEntityConfig("sequential", config);
175
+
176
+ expect(entityConfig.concurrency).toBe(1);
177
+ expect(consoleSpy).not.toHaveBeenCalled();
178
+
179
+ consoleSpy.mockRestore();
180
+ });
181
+
182
+ it("should not apply constraint when preserveRowOrder is false", () => {
183
+ const config = {
184
+ ...globalConfig,
185
+ entityConfig: {
186
+ ...globalConfig.entityConfig,
187
+ bulk: {
188
+ concurrency: 50,
189
+ preserveRowOrder: false,
190
+ },
191
+ },
192
+ };
193
+
194
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
195
+
196
+ const entityConfig = getEntityConfig("bulk", config);
197
+
198
+ expect(entityConfig.concurrency).toBe(50);
199
+ expect(consoleSpy).not.toHaveBeenCalled();
200
+
201
+ consoleSpy.mockRestore();
202
+ });
203
+ });
204
+ });
package/src/config.ts ADDED
@@ -0,0 +1,90 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import * as yaml from "js-yaml";
4
+
5
+ export interface ParallelProcessingConfig {
6
+ concurrency: number;
7
+ entityConcurrency: number;
8
+ preserveRowOrder: boolean;
9
+ }
10
+
11
+ export interface EntityConfig {
12
+ concurrency?: number;
13
+ preserveRowOrder?: boolean;
14
+ }
15
+
16
+ export interface ProcessingConfig {
17
+ parallelProcessing: ParallelProcessingConfig;
18
+ entityConfig: Record<string, EntityConfig>;
19
+ entityDependencies: Record<string, string[]>;
20
+ }
21
+
22
+ export interface FullConfig extends ProcessingConfig {
23
+ // Future: additional config sections can be added here
24
+ }
25
+
26
+ export const DEFAULT_PARALLEL_CONFIG: ParallelProcessingConfig = {
27
+ concurrency: 1,
28
+ entityConcurrency: 1,
29
+ preserveRowOrder: true,
30
+ };
31
+
32
+ export const DEFAULT_CONFIG: ProcessingConfig = {
33
+ parallelProcessing: DEFAULT_PARALLEL_CONFIG,
34
+ entityConfig: {},
35
+ entityDependencies: {},
36
+ };
37
+
38
+ export function loadConfig(configDir: string): ProcessingConfig {
39
+ const configPath = path.join(configDir, "config.yaml");
40
+
41
+ try {
42
+ if (!fs.existsSync(configPath)) {
43
+ console.log("No config.yaml found, using default sequential processing");
44
+ return DEFAULT_CONFIG;
45
+ }
46
+
47
+ const fileContents = fs.readFileSync(configPath, "utf8");
48
+ const yamlConfig = yaml.load(fileContents) as Partial<FullConfig>;
49
+
50
+ return mergeWithDefaults(yamlConfig);
51
+ } catch (error) {
52
+ console.warn(
53
+ `Warning: Failed to parse config.yaml: ${error}. Using defaults.`
54
+ );
55
+ return DEFAULT_CONFIG;
56
+ }
57
+ }
58
+
59
+ function mergeWithDefaults(yamlConfig: Partial<FullConfig>): ProcessingConfig {
60
+ return {
61
+ parallelProcessing: {
62
+ ...DEFAULT_PARALLEL_CONFIG,
63
+ ...(yamlConfig.parallelProcessing || {}),
64
+ },
65
+ entityConfig: yamlConfig.entityConfig || {},
66
+ entityDependencies: yamlConfig.entityDependencies || {},
67
+ };
68
+ }
69
+
70
+ export function getEntityConfig(
71
+ entityName: string,
72
+ globalConfig: ProcessingConfig
73
+ ): ParallelProcessingConfig {
74
+ const entityOverrides = globalConfig.entityConfig[entityName] || {};
75
+
76
+ let finalConfig = {
77
+ ...globalConfig.parallelProcessing,
78
+ ...entityOverrides,
79
+ };
80
+
81
+ // Apply constraint: preserveRowOrder forces concurrency = 1
82
+ if (finalConfig.preserveRowOrder && finalConfig.concurrency > 1) {
83
+ console.warn(
84
+ `Entity '${entityName}': preserveRowOrder=true forces concurrency=1 (was ${finalConfig.concurrency})`
85
+ );
86
+ finalConfig.concurrency = 1;
87
+ }
88
+
89
+ return finalConfig;
90
+ }
@@ -0,0 +1,197 @@
1
+ import { DependencyResolver } from "./dependency-resolver";
2
+
3
+ describe("DependencyResolver", () => {
4
+ describe("resolveExecutionOrder", () => {
5
+ it("should handle entities with no dependencies", () => {
6
+ const entities = ["users", "products", "orders"];
7
+ const dependencies = {};
8
+ const resolver = new DependencyResolver(entities, dependencies);
9
+
10
+ const waves = resolver.resolveExecutionOrder();
11
+
12
+ expect(waves).toHaveLength(1);
13
+ expect(waves[0].wave).toBe(0);
14
+ expect(waves[0].entities).toEqual(["users", "products", "orders"]);
15
+ });
16
+
17
+ it("should resolve simple linear dependencies", () => {
18
+ const entities = ["users", "products", "orders"];
19
+ const dependencies = {
20
+ products: ["users"],
21
+ orders: ["products"],
22
+ };
23
+ const resolver = new DependencyResolver(entities, dependencies);
24
+
25
+ const waves = resolver.resolveExecutionOrder();
26
+
27
+ expect(waves).toHaveLength(3);
28
+ expect(waves[0].entities).toEqual(["users"]);
29
+ expect(waves[1].entities).toEqual(["products"]);
30
+ expect(waves[2].entities).toEqual(["orders"]);
31
+ });
32
+
33
+ it("should resolve complex dependency graph", () => {
34
+ const entities = ["users", "categories", "products", "orders", "reviews"];
35
+ const dependencies = {
36
+ products: ["users", "categories"],
37
+ orders: ["users", "products"],
38
+ reviews: ["users", "products"],
39
+ };
40
+ const resolver = new DependencyResolver(entities, dependencies);
41
+
42
+ const waves = resolver.resolveExecutionOrder();
43
+
44
+ expect(waves).toHaveLength(3);
45
+
46
+ // Wave 0: users and categories (no dependencies)
47
+ expect(waves[0].entities.sort()).toEqual(["categories", "users"]);
48
+
49
+ // Wave 1: products (depends on users and categories)
50
+ expect(waves[1].entities).toEqual(["products"]);
51
+
52
+ // Wave 2: orders and reviews (both depend on users and products)
53
+ expect(waves[2].entities.sort()).toEqual(["orders", "reviews"]);
54
+ });
55
+
56
+ it("should handle entities with multiple dependencies", () => {
57
+ const entities = ["a", "b", "c", "d"];
58
+ const dependencies = {
59
+ c: ["a", "b"],
60
+ d: ["a", "b"],
61
+ };
62
+ const resolver = new DependencyResolver(entities, dependencies);
63
+
64
+ const waves = resolver.resolveExecutionOrder();
65
+
66
+ expect(waves).toHaveLength(2);
67
+ expect(waves[0].entities.sort()).toEqual(["a", "b"]);
68
+ expect(waves[1].entities.sort()).toEqual(["c", "d"]);
69
+ });
70
+
71
+ it("should detect circular dependencies", () => {
72
+ const entities = ["a", "b", "c"];
73
+ const dependencies = {
74
+ a: ["b"],
75
+ b: ["c"],
76
+ c: ["a"], // circular
77
+ };
78
+ const resolver = new DependencyResolver(entities, dependencies);
79
+
80
+ expect(() => resolver.resolveExecutionOrder()).toThrow(
81
+ "Circular dependency detected or missing dependencies for entities: a, b, c"
82
+ );
83
+ });
84
+
85
+ it("should detect missing dependencies", () => {
86
+ const entities = ["a", "b"];
87
+ const dependencies = {
88
+ a: ["missing"],
89
+ b: ["a"],
90
+ };
91
+ const resolver = new DependencyResolver(entities, dependencies);
92
+
93
+ expect(() => resolver.resolveExecutionOrder()).toThrow(
94
+ "Circular dependency detected or missing dependencies for entities: a, b"
95
+ );
96
+ });
97
+ });
98
+
99
+ describe("validateDependencies", () => {
100
+ it("should return no errors for valid dependencies", () => {
101
+ const entities = ["users", "products", "orders"];
102
+ const dependencies = {
103
+ products: ["users"],
104
+ orders: ["users", "products"],
105
+ };
106
+ const resolver = new DependencyResolver(entities, dependencies);
107
+
108
+ const errors = resolver.validateDependencies();
109
+
110
+ expect(errors).toHaveLength(0);
111
+ });
112
+
113
+ it("should detect entity with dependencies not in entity list", () => {
114
+ const entities = ["users", "products"];
115
+ const dependencies = {
116
+ products: ["users"],
117
+ orders: ["products"], // orders not in entities list
118
+ };
119
+ const resolver = new DependencyResolver(entities, dependencies);
120
+
121
+ const errors = resolver.validateDependencies();
122
+
123
+ expect(errors).toContain(
124
+ "Entity 'orders' has dependencies but is not in the entity list"
125
+ );
126
+ });
127
+
128
+ it("should detect dependencies on non-existent entities", () => {
129
+ const entities = ["users", "products"];
130
+ const dependencies = {
131
+ products: ["users", "categories"], // categories doesn't exist
132
+ };
133
+ const resolver = new DependencyResolver(entities, dependencies);
134
+
135
+ const errors = resolver.validateDependencies();
136
+
137
+ expect(errors).toContain(
138
+ "Entity 'products' depends on 'categories' which does not exist"
139
+ );
140
+ });
141
+
142
+ it("should detect multiple validation errors", () => {
143
+ const entities = ["users"];
144
+ const dependencies = {
145
+ products: ["categories"], // products not in list, categories doesn't exist
146
+ orders: ["missing"], // orders not in list, missing doesn't exist
147
+ };
148
+ const resolver = new DependencyResolver(entities, dependencies);
149
+
150
+ const errors = resolver.validateDependencies();
151
+
152
+ expect(errors).toHaveLength(2);
153
+ expect(errors).toContain(
154
+ "Entity 'products' has dependencies but is not in the entity list"
155
+ );
156
+ expect(errors).toContain(
157
+ "Entity 'orders' has dependencies but is not in the entity list"
158
+ );
159
+ });
160
+ });
161
+
162
+ describe("getDependents", () => {
163
+ it("should return entities that depend on the given entity", () => {
164
+ const entities = ["users", "products", "orders", "reviews"];
165
+ const dependencies = {
166
+ products: ["users"],
167
+ orders: ["users", "products"],
168
+ reviews: ["users", "products"],
169
+ };
170
+ const resolver = new DependencyResolver(entities, dependencies);
171
+
172
+ const usersDependents = resolver.getDependents("users");
173
+ const productsDependents = resolver.getDependents("products");
174
+ const ordersDependents = resolver.getDependents("orders");
175
+
176
+ expect(usersDependents.sort()).toEqual(["orders", "products", "reviews"]);
177
+ expect(productsDependents.sort()).toEqual(["orders", "reviews"]);
178
+ expect(ordersDependents).toEqual([]);
179
+ });
180
+ });
181
+
182
+ describe("getDependencies", () => {
183
+ it("should return dependencies for the given entity", () => {
184
+ const entities = ["users", "products", "orders"];
185
+ const dependencies = {
186
+ products: ["users"],
187
+ orders: ["users", "products"],
188
+ };
189
+ const resolver = new DependencyResolver(entities, dependencies);
190
+
191
+ expect(resolver.getDependencies("users")).toEqual([]);
192
+ expect(resolver.getDependencies("products")).toEqual(["users"]);
193
+ expect(resolver.getDependencies("orders")).toEqual(["users", "products"]);
194
+ expect(resolver.getDependencies("nonexistent")).toEqual([]);
195
+ });
196
+ });
197
+ });