@jackchuka/gql-ingest 1.0.1 → 1.1.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,205 @@
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
+ enableEntityParallelization: true
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.enableEntityParallelization).toBe(true);
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.enableEntityParallelization).toBe(false); // 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
+ enableEntityParallelization: true,
118
+ preserveRowOrder: false,
119
+ preserveEntityOrder: false,
120
+ },
121
+ entityConfig: {
122
+ users: {
123
+ concurrency: 2,
124
+ preserveRowOrder: true,
125
+ },
126
+ products: {
127
+ concurrency: 20,
128
+ },
129
+ },
130
+ entityDependencies: {},
131
+ };
132
+
133
+ it("should return global config for entity without overrides", () => {
134
+ const entityConfig = getEntityConfig("orders", globalConfig);
135
+
136
+ expect(entityConfig).toEqual(globalConfig.parallelProcessing);
137
+ });
138
+
139
+ it("should merge entity overrides with global config", () => {
140
+ const entityConfig = getEntityConfig("products", globalConfig);
141
+
142
+ expect(entityConfig.concurrency).toBe(20); // overridden
143
+ expect(entityConfig.enableEntityParallelization).toBe(true); // from global
144
+ expect(entityConfig.preserveRowOrder).toBe(false); // from global
145
+ });
146
+
147
+ it("should apply preserveRowOrder constraint", () => {
148
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
149
+
150
+ const entityConfig = getEntityConfig("users", globalConfig);
151
+
152
+ expect(entityConfig.concurrency).toBe(1); // forced to 1
153
+ expect(entityConfig.preserveRowOrder).toBe(true);
154
+ expect(consoleSpy).toHaveBeenCalledWith(
155
+ "Entity 'users': preserveRowOrder=true forces concurrency=1 (was 2)"
156
+ );
157
+
158
+ consoleSpy.mockRestore();
159
+ });
160
+
161
+ it("should not apply constraint when concurrency is already 1", () => {
162
+ const config = {
163
+ ...globalConfig,
164
+ entityConfig: {
165
+ ...globalConfig.entityConfig,
166
+ sequential: {
167
+ concurrency: 1,
168
+ preserveRowOrder: true,
169
+ },
170
+ },
171
+ };
172
+
173
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
174
+
175
+ const entityConfig = getEntityConfig("sequential", config);
176
+
177
+ expect(entityConfig.concurrency).toBe(1);
178
+ expect(consoleSpy).not.toHaveBeenCalled();
179
+
180
+ consoleSpy.mockRestore();
181
+ });
182
+
183
+ it("should not apply constraint when preserveRowOrder is false", () => {
184
+ const config = {
185
+ ...globalConfig,
186
+ entityConfig: {
187
+ ...globalConfig.entityConfig,
188
+ bulk: {
189
+ concurrency: 50,
190
+ preserveRowOrder: false,
191
+ },
192
+ },
193
+ };
194
+
195
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
196
+
197
+ const entityConfig = getEntityConfig("bulk", config);
198
+
199
+ expect(entityConfig.concurrency).toBe(50);
200
+ expect(consoleSpy).not.toHaveBeenCalled();
201
+
202
+ consoleSpy.mockRestore();
203
+ });
204
+ });
205
+ });
package/src/config.ts ADDED
@@ -0,0 +1,92 @@
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
+ enableEntityParallelization: boolean;
8
+ preserveRowOrder: boolean;
9
+ preserveEntityOrder: boolean;
10
+ }
11
+
12
+ export interface EntityConfig {
13
+ concurrency?: number;
14
+ preserveRowOrder?: boolean;
15
+ }
16
+
17
+ export interface ProcessingConfig {
18
+ parallelProcessing: ParallelProcessingConfig;
19
+ entityConfig: Record<string, EntityConfig>;
20
+ entityDependencies: Record<string, string[]>;
21
+ }
22
+
23
+ export interface FullConfig extends ProcessingConfig {
24
+ // Future: additional config sections can be added here
25
+ }
26
+
27
+ export const DEFAULT_PARALLEL_CONFIG: ParallelProcessingConfig = {
28
+ concurrency: 1,
29
+ enableEntityParallelization: false,
30
+ preserveRowOrder: true,
31
+ preserveEntityOrder: true,
32
+ };
33
+
34
+ export const DEFAULT_CONFIG: ProcessingConfig = {
35
+ parallelProcessing: DEFAULT_PARALLEL_CONFIG,
36
+ entityConfig: {},
37
+ entityDependencies: {},
38
+ };
39
+
40
+ export function loadConfig(configDir: string): ProcessingConfig {
41
+ const configPath = path.join(configDir, "config.yaml");
42
+
43
+ try {
44
+ if (!fs.existsSync(configPath)) {
45
+ console.log("No config.yaml found, using default sequential processing");
46
+ return DEFAULT_CONFIG;
47
+ }
48
+
49
+ const fileContents = fs.readFileSync(configPath, "utf8");
50
+ const yamlConfig = yaml.load(fileContents) as Partial<FullConfig>;
51
+
52
+ return mergeWithDefaults(yamlConfig);
53
+ } catch (error) {
54
+ console.warn(
55
+ `Warning: Failed to parse config.yaml: ${error}. Using defaults.`
56
+ );
57
+ return DEFAULT_CONFIG;
58
+ }
59
+ }
60
+
61
+ function mergeWithDefaults(yamlConfig: Partial<FullConfig>): ProcessingConfig {
62
+ return {
63
+ parallelProcessing: {
64
+ ...DEFAULT_PARALLEL_CONFIG,
65
+ ...(yamlConfig.parallelProcessing || {}),
66
+ },
67
+ entityConfig: yamlConfig.entityConfig || {},
68
+ entityDependencies: yamlConfig.entityDependencies || {},
69
+ };
70
+ }
71
+
72
+ export function getEntityConfig(
73
+ entityName: string,
74
+ globalConfig: ProcessingConfig
75
+ ): ParallelProcessingConfig {
76
+ const entityOverrides = globalConfig.entityConfig[entityName] || {};
77
+
78
+ let finalConfig = {
79
+ ...globalConfig.parallelProcessing,
80
+ ...entityOverrides,
81
+ };
82
+
83
+ // Apply constraint: preserveRowOrder forces concurrency = 1
84
+ if (finalConfig.preserveRowOrder && finalConfig.concurrency > 1) {
85
+ console.warn(
86
+ `Entity '${entityName}': preserveRowOrder=true forces concurrency=1 (was ${finalConfig.concurrency})`
87
+ );
88
+ finalConfig.concurrency = 1;
89
+ }
90
+
91
+ return finalConfig;
92
+ }
@@ -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
+ });
@@ -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
  }