nx-mongo 3.3.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,1660 @@
1
+ import { MongoClient, Db, Collection, Filter, UpdateFilter, OptionalUnlessRequiredId, Document, WithId, ClientSession, Sort, IndexSpecification, CreateIndexesOptions } from 'mongodb';
2
+ import { createHash } from 'crypto';
3
+
4
+ export interface PaginationOptions {
5
+ page?: number;
6
+ limit?: number;
7
+ sort?: Sort;
8
+ }
9
+
10
+ export interface PaginatedResult<T> {
11
+ data: WithId<T>[];
12
+ total: number;
13
+ page: number;
14
+ limit: number;
15
+ totalPages: number;
16
+ hasNext: boolean;
17
+ hasPrev: boolean;
18
+ }
19
+
20
+ export interface RetryOptions {
21
+ maxRetries?: number;
22
+ retryDelay?: number;
23
+ exponentialBackoff?: boolean;
24
+ }
25
+
26
+ export interface InputConfig {
27
+ ref: string;
28
+ collection: string;
29
+ query?: Filter<any>;
30
+ }
31
+
32
+ export interface OutputConfig {
33
+ ref: string;
34
+ collection: string;
35
+ keys?: string[];
36
+ mode?: 'append' | 'replace';
37
+ }
38
+
39
+ export interface HelperConfig {
40
+ inputs: InputConfig[];
41
+ outputs: OutputConfig[];
42
+ output?: {
43
+ mode?: 'append' | 'replace';
44
+ };
45
+ progress?: {
46
+ collection?: string;
47
+ uniqueIndexKeys?: string[];
48
+ provider?: string;
49
+ };
50
+ }
51
+
52
+ export interface WriteByRefResult {
53
+ inserted: number;
54
+ updated: number;
55
+ errors: Array<{ index: number; error: Error; doc?: any }>;
56
+ indexCreated: boolean;
57
+ }
58
+
59
+ export interface EnsureSignatureIndexResult {
60
+ created: boolean;
61
+ indexName: string;
62
+ }
63
+
64
+ export interface StageIdentity {
65
+ key: string;
66
+ process?: string;
67
+ provider?: string;
68
+ name?: string;
69
+ }
70
+
71
+ export interface StageMetadata {
72
+ itemCount?: number;
73
+ errorCount?: number;
74
+ durationMs?: number;
75
+ [key: string]: any;
76
+ }
77
+
78
+ export interface StageRecord extends StageIdentity {
79
+ completed: boolean;
80
+ startedAt?: Date;
81
+ completedAt?: Date;
82
+ metadata?: StageMetadata;
83
+ }
84
+
85
+ export type ProgressSessionOptions = { session?: ClientSession };
86
+
87
+ export interface ProgressAPI {
88
+ isCompleted(key: string, options?: ProgressSessionOptions & { process?: string; provider?: string }): Promise<boolean>;
89
+ start(identity: StageIdentity, options?: ProgressSessionOptions): Promise<void>;
90
+ complete(
91
+ identity: StageIdentity & { metadata?: StageMetadata },
92
+ options?: ProgressSessionOptions
93
+ ): Promise<void>;
94
+ getCompleted(options?: ProgressSessionOptions & { process?: string; provider?: string }): Promise<Array<Pick<StageRecord, 'key' | 'name' | 'completedAt'>>>;
95
+ getProgress(options?: ProgressSessionOptions & { process?: string; provider?: string }): Promise<StageRecord[]>;
96
+ reset(key: string, options?: ProgressSessionOptions & { process?: string; provider?: string }): Promise<void>;
97
+ }
98
+
99
+ export interface WriteStageOptions {
100
+ ensureIndex?: boolean;
101
+ session?: ClientSession;
102
+ complete?: {
103
+ key: string;
104
+ process?: string;
105
+ name?: string;
106
+ provider?: string;
107
+ metadata?: StageMetadata;
108
+ };
109
+ }
110
+
111
+ export interface WriteStageResult extends WriteByRefResult {
112
+ completed?: boolean;
113
+ }
114
+
115
+ /**
116
+ * Extracts values from an object using dot-notation paths with array wildcard support.
117
+ * Supports nested object paths (dot notation) and array wildcards ([]) to collect values from all array elements.
118
+ * @param value - The object to extract values from
119
+ * @param path - Dot-notation path (e.g., "meta.id", "edges[].from", "segments[]")
120
+ * @returns Array of extracted values (flattened and deduplicated for arrays)
121
+ * @example
122
+ * getByDotPath({ segments: [1, 2, 3] }, "segments[]") // [1, 2, 3]
123
+ * getByDotPath({ edges: [{ from: "A" }, { from: "B" }] }, "edges[].from") // ["A", "B"]
124
+ * getByDotPath({ meta: { id: "123" } }, "meta.id") // ["123"]
125
+ */
126
+ export function getByDotPath(value: any, path: string): any[] {
127
+ if (value == null) {
128
+ return [];
129
+ }
130
+
131
+ // Handle array wildcard at the end (e.g., "segments[]", "edges[]")
132
+ if (path.endsWith('[]')) {
133
+ const basePath = path.slice(0, -2);
134
+ const arrayValue = getByDotPath(value, basePath);
135
+
136
+ if (!Array.isArray(arrayValue)) {
137
+ return [];
138
+ }
139
+
140
+ // Flatten nested arrays recursively
141
+ const flatten = (arr: any[]): any[] => {
142
+ const result: any[] = [];
143
+ for (const item of arr) {
144
+ if (Array.isArray(item)) {
145
+ result.push(...flatten(item));
146
+ } else {
147
+ result.push(item);
148
+ }
149
+ }
150
+ return result;
151
+ };
152
+
153
+ return flatten(arrayValue);
154
+ }
155
+
156
+ // Handle array wildcard in the middle (e.g., "edges[].from", "items[].meta.id")
157
+ const arrayWildcardMatch = path.match(/^(.+?)\[\]\.(.+)$/);
158
+ if (arrayWildcardMatch) {
159
+ const [, arrayPath, remainingPath] = arrayWildcardMatch;
160
+ const arrayValue = getByDotPath(value, arrayPath);
161
+
162
+ if (!Array.isArray(arrayValue)) {
163
+ return [];
164
+ }
165
+
166
+ // Collect values from all array elements
167
+ const results: any[] = [];
168
+ for (const item of arrayValue) {
169
+ const nestedValues = getByDotPath(item, remainingPath);
170
+ results.push(...nestedValues);
171
+ }
172
+
173
+ // Flatten nested arrays recursively
174
+ const flatten = (arr: any[]): any[] => {
175
+ const result: any[] = [];
176
+ for (const item of arr) {
177
+ if (Array.isArray(item)) {
178
+ result.push(...flatten(item));
179
+ } else {
180
+ result.push(item);
181
+ }
182
+ }
183
+ return result;
184
+ };
185
+
186
+ return flatten(results);
187
+ }
188
+
189
+ // Handle simple dot-notation path (e.g., "meta.id")
190
+ const parts = path.split('.');
191
+ let current: any = value;
192
+
193
+ for (const part of parts) {
194
+ if (current == null) {
195
+ return [];
196
+ }
197
+
198
+ if (Array.isArray(current)) {
199
+ // If we hit an array in the middle of the path, collect from all elements
200
+ const results: any[] = [];
201
+ for (const item of current) {
202
+ const nestedValue = getByDotPath(item, parts.slice(parts.indexOf(part)).join('.'));
203
+ results.push(...nestedValue);
204
+ }
205
+ return results;
206
+ }
207
+
208
+ current = current[part];
209
+ }
210
+
211
+ return current != null ? [current] : [];
212
+ }
213
+
214
+ /**
215
+ * Normalizes a value to a string representation for signature computation.
216
+ * @param value - The value to normalize
217
+ * @returns Normalized string representation
218
+ */
219
+ function normalizeValue(value: any): string {
220
+ if (value === null || value === undefined) {
221
+ return 'null';
222
+ }
223
+
224
+ if (typeof value === 'string') {
225
+ return value;
226
+ }
227
+
228
+ if (typeof value === 'number') {
229
+ return String(value);
230
+ }
231
+
232
+ if (typeof value === 'boolean') {
233
+ return String(value);
234
+ }
235
+
236
+ if (value instanceof Date) {
237
+ return value.toISOString();
238
+ }
239
+
240
+ if (Array.isArray(value)) {
241
+ // Flatten, normalize, deduplicate, and sort
242
+ const flatten = (arr: any[]): any[] => {
243
+ const result: any[] = [];
244
+ for (const item of arr) {
245
+ if (Array.isArray(item)) {
246
+ result.push(...flatten(item));
247
+ } else {
248
+ result.push(item);
249
+ }
250
+ }
251
+ return result;
252
+ };
253
+
254
+ const flattened = flatten(value);
255
+ const normalized = flattened.map(normalizeValue);
256
+ const unique = Array.from(new Set(normalized));
257
+ const sorted = unique.sort();
258
+ return JSON.stringify(sorted);
259
+ }
260
+
261
+ if (typeof value === 'object') {
262
+ // Sort keys for consistent stringification
263
+ const sortedKeys = Object.keys(value).sort();
264
+ const sortedObj: any = {};
265
+ for (const key of sortedKeys) {
266
+ sortedObj[key] = value[key];
267
+ }
268
+ return JSON.stringify(sortedObj);
269
+ }
270
+
271
+ return String(value);
272
+ }
273
+
274
+ /**
275
+ * Computes a deterministic signature for a document based on specified keys.
276
+ * The signature is a SHA-256 hash of a canonical representation of the key values.
277
+ * @param doc - The document to compute signature for
278
+ * @param keys - Array of dot-notation paths to extract values from
279
+ * @param options - Optional configuration
280
+ * @param options.algorithm - Hash algorithm to use (default: "sha256")
281
+ * @returns Hex string signature
282
+ * @example
283
+ * computeSignature({ segments: [1, 2], role: "admin" }, ["segments[]", "role"])
284
+ */
285
+ export function computeSignature(
286
+ doc: any,
287
+ keys: string[],
288
+ options?: {
289
+ algorithm?: 'sha256' | 'sha1' | 'md5';
290
+ }
291
+ ): string {
292
+ const algorithm = options?.algorithm || 'sha256';
293
+
294
+ // Extract values for each key
295
+ const canonicalMap: Record<string, any[]> = {};
296
+
297
+ for (const key of keys) {
298
+ const values = getByDotPath(doc, key);
299
+ // Normalize and deduplicate values
300
+ const normalized = values.map(normalizeValue);
301
+ const unique = Array.from(new Set(normalized));
302
+ const sorted = unique.sort();
303
+ canonicalMap[key] = sorted;
304
+ }
305
+
306
+ // Sort keys alphabetically
307
+ const sortedKeys = Object.keys(canonicalMap).sort();
308
+ const sortedMap: Record<string, any[]> = {};
309
+ for (const key of sortedKeys) {
310
+ sortedMap[key] = canonicalMap[key];
311
+ }
312
+
313
+ // Stringify and hash
314
+ const jsonString = JSON.stringify(sortedMap);
315
+ const hash = createHash(algorithm);
316
+ hash.update(jsonString);
317
+ return hash.digest('hex');
318
+ }
319
+
320
+ /**
321
+ * Internal class that implements the ProgressAPI interface for stage tracking.
322
+ */
323
+ class ProgressAPIImpl implements ProgressAPI {
324
+ private helper: SimpleMongoHelper;
325
+ private config: { collection: string; uniqueIndexKeys: string[]; defaultProvider?: string };
326
+ private indexEnsured: boolean = false;
327
+
328
+ constructor(helper: SimpleMongoHelper, progressConfig?: HelperConfig['progress']) {
329
+ this.helper = helper;
330
+ this.config = {
331
+ collection: progressConfig?.collection || 'progress_states',
332
+ uniqueIndexKeys: progressConfig?.uniqueIndexKeys || ['process', 'provider', 'key'],
333
+ defaultProvider: progressConfig?.provider,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Ensures the progress collection and unique index exist.
339
+ * Called lazily on first use.
340
+ */
341
+ private async ensureProgressIndex(session?: ClientSession): Promise<void> {
342
+ if (this.indexEnsured) {
343
+ return;
344
+ }
345
+
346
+ this.helper.ensureInitialized();
347
+
348
+ try {
349
+ const collection = this.helper.getDb().collection(this.config.collection);
350
+ const indexes = await collection.indexes();
351
+
352
+ // Build index spec from uniqueIndexKeys
353
+ const indexSpec: IndexSpecification = {};
354
+ for (const key of this.config.uniqueIndexKeys) {
355
+ indexSpec[key] = 1;
356
+ }
357
+
358
+ // Check if index already exists
359
+ const existingIndex = indexes.find(idx => {
360
+ const keys = idx.key as Document;
361
+ const indexKeys = Object.keys(keys).sort();
362
+ const expectedKeys = Object.keys(indexSpec).sort();
363
+ return JSON.stringify(indexKeys) === JSON.stringify(expectedKeys);
364
+ });
365
+
366
+ if (!existingIndex) {
367
+ const indexOptions: CreateIndexesOptions = { unique: true };
368
+ if (session) {
369
+ indexOptions.session = session;
370
+ }
371
+ await collection.createIndex(indexSpec, indexOptions);
372
+ }
373
+
374
+ this.indexEnsured = true;
375
+ } catch (error) {
376
+ const errorMessage = error instanceof Error ? error.message : String(error);
377
+ if (errorMessage.includes('not authorized') || errorMessage.includes('unauthorized')) {
378
+ throw new Error(
379
+ `Failed to create progress index on collection '${this.config.collection}': ${errorMessage}. ` +
380
+ `This operation requires 'createIndex' privilege.`
381
+ );
382
+ }
383
+ throw new Error(`Failed to ensure progress index on collection '${this.config.collection}': ${errorMessage}`);
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Resolves the provider value, using options provider, then default provider, then undefined.
389
+ */
390
+ private resolveProvider(options?: { provider?: string }): string | undefined {
391
+ return options?.provider ?? this.config.defaultProvider;
392
+ }
393
+
394
+ /**
395
+ * Builds a filter for finding a stage record.
396
+ */
397
+ private buildFilter(key: string, process?: string, provider?: string): Filter<StageRecord> {
398
+ const filter: Filter<StageRecord> = { key };
399
+ if (process !== undefined) {
400
+ filter.process = process;
401
+ }
402
+ if (provider !== undefined) {
403
+ filter.provider = provider;
404
+ }
405
+ return filter;
406
+ }
407
+
408
+ async isCompleted(key: string, options?: ProgressSessionOptions & { process?: string; provider?: string }): Promise<boolean> {
409
+ await this.ensureProgressIndex(options?.session);
410
+ this.helper.ensureInitialized();
411
+
412
+ const provider = this.resolveProvider(options);
413
+ const process = options?.process;
414
+ const filter = this.buildFilter(key, process, provider);
415
+ const collection = this.helper.getDb().collection<StageRecord>(this.config.collection);
416
+
417
+ const findOptions: any = {};
418
+ if (options?.session) {
419
+ findOptions.session = options.session;
420
+ }
421
+
422
+ const record = await collection.findOne(filter, findOptions);
423
+ return record?.completed === true;
424
+ }
425
+
426
+ async start(identity: StageIdentity, options?: ProgressSessionOptions): Promise<void> {
427
+ await this.ensureProgressIndex(options?.session);
428
+ this.helper.ensureInitialized();
429
+
430
+ const provider = this.resolveProvider({ provider: identity.provider });
431
+ const process = identity.process;
432
+ const filter = this.buildFilter(identity.key, process, provider);
433
+ const collection = this.helper.getDb().collection<StageRecord>(this.config.collection);
434
+
435
+ const update: UpdateFilter<StageRecord> = {
436
+ $set: {
437
+ key: identity.key,
438
+ name: identity.name,
439
+ completed: false,
440
+ },
441
+ $setOnInsert: {
442
+ startedAt: new Date(),
443
+ },
444
+ };
445
+
446
+ if (process !== undefined) {
447
+ (update.$set as any).process = process;
448
+ }
449
+ if (provider !== undefined) {
450
+ (update.$set as any).provider = provider;
451
+ }
452
+
453
+ const updateOptions: any = { upsert: true };
454
+ if (options?.session) {
455
+ updateOptions.session = options.session;
456
+ }
457
+
458
+ await collection.updateOne(filter, update, updateOptions);
459
+ }
460
+
461
+ async complete(
462
+ identity: StageIdentity & { metadata?: StageMetadata },
463
+ options?: ProgressSessionOptions
464
+ ): Promise<void> {
465
+ await this.ensureProgressIndex(options?.session);
466
+ this.helper.ensureInitialized();
467
+
468
+ const provider = this.resolveProvider({ provider: identity.provider });
469
+ const process = identity.process;
470
+ const filter = this.buildFilter(identity.key, process, provider);
471
+ const collection = this.helper.getDb().collection<StageRecord>(this.config.collection);
472
+
473
+ const update: UpdateFilter<StageRecord> = {
474
+ $set: {
475
+ key: identity.key,
476
+ completed: true,
477
+ completedAt: new Date(),
478
+ },
479
+ };
480
+
481
+ if (identity.name !== undefined) {
482
+ (update.$set as any).name = identity.name;
483
+ }
484
+
485
+ if (process !== undefined) {
486
+ (update.$set as any).process = process;
487
+ }
488
+ if (provider !== undefined) {
489
+ (update.$set as any).provider = provider;
490
+ }
491
+
492
+ if (identity.metadata) {
493
+ // Merge metadata
494
+ (update.$set as any).metadata = identity.metadata;
495
+ }
496
+
497
+ const updateOptions: any = { upsert: true };
498
+ if (options?.session) {
499
+ updateOptions.session = options.session;
500
+ }
501
+
502
+ await collection.updateOne(filter, update, updateOptions);
503
+ }
504
+
505
+ async getCompleted(options?: ProgressSessionOptions & { process?: string; provider?: string }): Promise<Array<Pick<StageRecord, 'key' | 'name' | 'completedAt'>>> {
506
+ await this.ensureProgressIndex(options?.session);
507
+ this.helper.ensureInitialized();
508
+
509
+ const provider = this.resolveProvider(options);
510
+ const process = options?.process;
511
+ const filter: Filter<StageRecord> = { completed: true };
512
+ if (process !== undefined) {
513
+ filter.process = process;
514
+ }
515
+ if (provider !== undefined) {
516
+ filter.provider = provider;
517
+ }
518
+
519
+ const collection = this.helper.getDb().collection<StageRecord>(this.config.collection);
520
+ const findOptions: any = { projection: { key: 1, name: 1, completedAt: 1 } };
521
+ if (options?.session) {
522
+ findOptions.session = options.session;
523
+ }
524
+
525
+ const records = await collection.find(filter, findOptions).toArray();
526
+ return records.map(r => ({
527
+ key: r.key,
528
+ name: r.name,
529
+ completedAt: r.completedAt,
530
+ }));
531
+ }
532
+
533
+ async getProgress(options?: ProgressSessionOptions & { process?: string; provider?: string }): Promise<StageRecord[]> {
534
+ await this.ensureProgressIndex(options?.session);
535
+ this.helper.ensureInitialized();
536
+
537
+ const provider = this.resolveProvider(options);
538
+ const process = options?.process;
539
+ const filter: Filter<StageRecord> = {};
540
+ if (process !== undefined) {
541
+ filter.process = process;
542
+ }
543
+ if (provider !== undefined) {
544
+ filter.provider = provider;
545
+ }
546
+
547
+ const collection = this.helper.getDb().collection<StageRecord>(this.config.collection);
548
+ const findOptions: any = {};
549
+ if (options?.session) {
550
+ findOptions.session = options.session;
551
+ }
552
+
553
+ return await collection.find(filter, findOptions).toArray();
554
+ }
555
+
556
+ async reset(key: string, options?: ProgressSessionOptions & { process?: string; provider?: string }): Promise<void> {
557
+ await this.ensureProgressIndex(options?.session);
558
+ this.helper.ensureInitialized();
559
+
560
+ const provider = this.resolveProvider(options);
561
+ const process = options?.process;
562
+ const filter = this.buildFilter(key, process, provider);
563
+ const collection = this.helper.getDb().collection<StageRecord>(this.config.collection);
564
+
565
+ const update: UpdateFilter<StageRecord> = {
566
+ $set: {
567
+ completed: false,
568
+ },
569
+ $unset: {
570
+ completedAt: '',
571
+ metadata: '',
572
+ },
573
+ };
574
+
575
+ const updateOptions: any = {};
576
+ if (options?.session) {
577
+ updateOptions.session = options.session;
578
+ }
579
+
580
+ await collection.updateOne(filter, update, updateOptions);
581
+ }
582
+ }
583
+
584
+ export class SimpleMongoHelper {
585
+ private connectionString: string;
586
+ private client: MongoClient | null = null;
587
+ protected db: Db | null = null;
588
+ private isInitialized: boolean = false;
589
+ private retryOptions: RetryOptions;
590
+ private config: HelperConfig | null = null;
591
+ public readonly progress: ProgressAPI;
592
+
593
+ constructor(connectionString: string, retryOptions?: RetryOptions, config?: HelperConfig) {
594
+ this.connectionString = connectionString;
595
+ this.retryOptions = {
596
+ maxRetries: retryOptions?.maxRetries ?? 3,
597
+ retryDelay: retryOptions?.retryDelay ?? 1000,
598
+ exponentialBackoff: retryOptions?.exponentialBackoff ?? true,
599
+ };
600
+ this.config = config || null;
601
+ this.progress = new ProgressAPIImpl(this, config?.progress);
602
+ }
603
+
604
+ /**
605
+ * Sets or updates the configuration for ref-based operations.
606
+ * @param config - Configuration object with inputs and outputs
607
+ * @returns This instance for method chaining
608
+ */
609
+ useConfig(config: HelperConfig): this {
610
+ this.config = config;
611
+ return this;
612
+ }
613
+
614
+ /**
615
+ * Tests the MongoDB connection and returns detailed error information if it fails.
616
+ * This method does not establish a persistent connection - use initialize() for that.
617
+ * @returns Object with success status and optional error details
618
+ */
619
+ async testConnection(): Promise<{
620
+ success: boolean;
621
+ error?: {
622
+ type: 'missing_credentials' | 'invalid_connection_string' | 'connection_failed' | 'authentication_failed' | 'config_error' | 'unknown';
623
+ message: string;
624
+ details?: string;
625
+ };
626
+ }> {
627
+ // Check connection string
628
+ if (!this.connectionString || this.connectionString.trim() === '') {
629
+ return {
630
+ success: false,
631
+ error: {
632
+ type: 'invalid_connection_string',
633
+ message: 'Connection string is missing or empty',
634
+ details: 'Provide a valid MongoDB connection string in the format: mongodb://[username:password@]host[:port][/database][?options]'
635
+ }
636
+ };
637
+ }
638
+
639
+ // Validate connection string format
640
+ try {
641
+ const url = new URL(this.connectionString);
642
+ if (url.protocol !== 'mongodb:' && url.protocol !== 'mongodb+srv:') {
643
+ return {
644
+ success: false,
645
+ error: {
646
+ type: 'invalid_connection_string',
647
+ message: 'Invalid connection string protocol',
648
+ details: `Expected protocol 'mongodb://' or 'mongodb+srv://', got '${url.protocol}'`
649
+ }
650
+ };
651
+ }
652
+ } catch (urlError) {
653
+ return {
654
+ success: false,
655
+ error: {
656
+ type: 'invalid_connection_string',
657
+ message: 'Connection string is not a valid URL',
658
+ details: `Failed to parse connection string: ${urlError instanceof Error ? urlError.message : String(urlError)}`
659
+ }
660
+ };
661
+ }
662
+
663
+ // Check for credentials in connection string
664
+ const hasCredentials = this.connectionString.includes('@') &&
665
+ (this.connectionString.match(/:\/\/[^:]+:[^@]+@/) ||
666
+ this.connectionString.match(/:\/\/[^@]+@/));
667
+
668
+ // Try to extract username/password from connection string
669
+ const authMatch = this.connectionString.match(/:\/\/([^:]+):([^@]+)@/);
670
+ if (authMatch) {
671
+ const username = authMatch[1];
672
+ const password = authMatch[2];
673
+ if (!username || username.trim() === '') {
674
+ return {
675
+ success: false,
676
+ error: {
677
+ type: 'missing_credentials',
678
+ message: 'Username is missing in connection string',
679
+ details: 'Connection string contains @ but username is empty. Format: mongodb://username:password@host:port/database'
680
+ }
681
+ };
682
+ }
683
+ if (!password || password.trim() === '') {
684
+ return {
685
+ success: false,
686
+ error: {
687
+ type: 'missing_credentials',
688
+ message: 'Password is missing in connection string',
689
+ details: 'Connection string contains @ but password is empty. Format: mongodb://username:password@host:port/database'
690
+ }
691
+ };
692
+ }
693
+ } else if (this.connectionString.includes('@')) {
694
+ // Has @ but no password format - might be using connection string options
695
+ // This is okay, continue to connection test
696
+ }
697
+
698
+ // Test actual connection
699
+ let testClient: MongoClient | null = null;
700
+ try {
701
+ testClient = new MongoClient(this.connectionString, {
702
+ serverSelectionTimeoutMS: 5000, // 5 second timeout for test
703
+ connectTimeoutMS: 5000
704
+ });
705
+
706
+ await testClient.connect();
707
+
708
+ // Try to ping the server
709
+ const dbName = this.extractDatabaseName(this.connectionString);
710
+ const testDb = testClient.db(dbName);
711
+ await testDb.admin().ping();
712
+
713
+ // Test basic operation (list collections)
714
+ try {
715
+ await testDb.listCollections().toArray();
716
+ } catch (listError) {
717
+ // If listCollections fails, might be permission issue but connection works
718
+ const errorMsg = listError instanceof Error ? listError.message : String(listError);
719
+ if (errorMsg.includes('not authorized') || errorMsg.includes('unauthorized')) {
720
+ await testClient.close();
721
+ return {
722
+ success: false,
723
+ error: {
724
+ type: 'authentication_failed',
725
+ message: 'Connection successful but insufficient permissions',
726
+ details: `Connected to MongoDB but cannot list collections. Error: ${errorMsg}. Check user permissions.`
727
+ }
728
+ };
729
+ }
730
+ }
731
+
732
+ await testClient.close();
733
+ return { success: true };
734
+
735
+ } catch (error) {
736
+ if (testClient) {
737
+ try {
738
+ await testClient.close();
739
+ } catch {
740
+ // Ignore close errors
741
+ }
742
+ }
743
+
744
+ const errorMessage = error instanceof Error ? error.message : String(error);
745
+ const errorString = String(error).toLowerCase();
746
+
747
+ // Analyze error type
748
+ if (errorString.includes('authentication failed') ||
749
+ errorString.includes('auth failed') ||
750
+ errorString.includes('invalid credentials') ||
751
+ errorMessage.includes('Authentication failed')) {
752
+ return {
753
+ success: false,
754
+ error: {
755
+ type: 'authentication_failed',
756
+ message: 'Authentication failed',
757
+ details: `Invalid username or password. Error: ${errorMessage}. Verify credentials in connection string.`
758
+ }
759
+ };
760
+ }
761
+
762
+ if (errorString.includes('timeout') ||
763
+ errorMessage.includes('timeout') ||
764
+ errorMessage.includes('ETIMEDOUT')) {
765
+ return {
766
+ success: false,
767
+ error: {
768
+ type: 'connection_failed',
769
+ message: 'Connection timeout',
770
+ details: `Cannot reach MongoDB server. Error: ${errorMessage}. Check if server is running, host/port is correct, and firewall allows connections.`
771
+ }
772
+ };
773
+ }
774
+
775
+ if (errorString.includes('dns') ||
776
+ errorMessage.includes('ENOTFOUND') ||
777
+ errorMessage.includes('getaddrinfo')) {
778
+ return {
779
+ success: false,
780
+ error: {
781
+ type: 'connection_failed',
782
+ message: 'DNS resolution failed',
783
+ details: `Cannot resolve hostname. Error: ${errorMessage}. Check if hostname is correct and DNS is configured properly.`
784
+ }
785
+ };
786
+ }
787
+
788
+ if (errorString.includes('refused') ||
789
+ errorMessage.includes('ECONNREFUSED')) {
790
+ return {
791
+ success: false,
792
+ error: {
793
+ type: 'connection_failed',
794
+ message: 'Connection refused',
795
+ details: `MongoDB server refused connection. Error: ${errorMessage}. Check if MongoDB is running on the specified host and port.`
796
+ }
797
+ };
798
+ }
799
+
800
+ if (errorString.includes('network') ||
801
+ errorMessage.includes('network')) {
802
+ return {
803
+ success: false,
804
+ error: {
805
+ type: 'connection_failed',
806
+ message: 'Network error',
807
+ details: `Network connectivity issue. Error: ${errorMessage}. Check network connection and firewall settings.`
808
+ }
809
+ };
810
+ }
811
+
812
+ // Generic connection error
813
+ return {
814
+ success: false,
815
+ error: {
816
+ type: 'connection_failed',
817
+ message: 'Connection failed',
818
+ details: `Failed to connect to MongoDB. Error: ${errorMessage}. Verify connection string, server status, and network connectivity.`
819
+ }
820
+ };
821
+ }
822
+ }
823
+
824
+ /**
825
+ * Establishes MongoDB connection internally with retry logic.
826
+ * Must be called before using other methods.
827
+ * @throws Error if connection fails or if already initialized
828
+ */
829
+ async initialize(): Promise<void> {
830
+ if (this.isInitialized) {
831
+ throw new Error('SimpleMongoHelper is already initialized');
832
+ }
833
+
834
+ let lastError: Error | null = null;
835
+ for (let attempt = 0; attempt <= this.retryOptions.maxRetries!; attempt++) {
836
+ try {
837
+ this.client = new MongoClient(this.connectionString);
838
+ await this.client.connect();
839
+ // Extract database name from connection string or use default
840
+ const dbName = this.extractDatabaseName(this.connectionString);
841
+ this.db = this.client.db(dbName);
842
+ this.isInitialized = true;
843
+ return;
844
+ } catch (error) {
845
+ lastError = error instanceof Error ? error : new Error(String(error));
846
+ this.client = null;
847
+ this.db = null;
848
+
849
+ if (attempt < this.retryOptions.maxRetries!) {
850
+ const delay = this.retryOptions.exponentialBackoff
851
+ ? this.retryOptions.retryDelay! * Math.pow(2, attempt)
852
+ : this.retryOptions.retryDelay!;
853
+ await new Promise(resolve => setTimeout(resolve, delay));
854
+ }
855
+ }
856
+ }
857
+
858
+ throw new Error(`Failed to initialize MongoDB connection after ${this.retryOptions.maxRetries! + 1} attempts: ${lastError?.message}`);
859
+ }
860
+
861
+ /**
862
+ * Loads data from a collection with optional query filter.
863
+ * @param collectionName - Name of the collection to query
864
+ * @param query - Optional MongoDB query filter
865
+ * @param options - Optional pagination and sorting options
866
+ * @returns Array of documents matching the query or paginated result
867
+ * @throws Error if not initialized or if query fails
868
+ */
869
+ async loadCollection<T extends Document = Document>(
870
+ collectionName: string,
871
+ query?: Filter<T>,
872
+ options?: PaginationOptions
873
+ ): Promise<WithId<T>[] | PaginatedResult<T>> {
874
+ this.ensureInitialized();
875
+
876
+ try {
877
+ const collection: Collection<T> = this.db!.collection<T>(collectionName);
878
+ const filter = query || {};
879
+ let cursor = collection.find(filter);
880
+
881
+ // Apply sorting if provided
882
+ if (options?.sort) {
883
+ cursor = cursor.sort(options.sort);
884
+ }
885
+
886
+ // Apply pagination if provided
887
+ if (options?.page && options?.limit) {
888
+ const page = Math.max(1, options.page);
889
+ const limit = Math.max(1, options.limit);
890
+ const skip = (page - 1) * limit;
891
+
892
+ cursor = cursor.skip(skip).limit(limit);
893
+
894
+ // Get total count for pagination
895
+ const total = await collection.countDocuments(filter);
896
+ const results = await cursor.toArray();
897
+ const totalPages = Math.ceil(total / limit);
898
+
899
+ return {
900
+ data: results,
901
+ total,
902
+ page,
903
+ limit,
904
+ totalPages,
905
+ hasNext: page < totalPages,
906
+ hasPrev: page > 1,
907
+ };
908
+ }
909
+
910
+ // Return all results if no pagination
911
+ const results = await cursor.toArray();
912
+ return results;
913
+ } catch (error) {
914
+ throw new Error(`Failed to load collection '${collectionName}': ${error instanceof Error ? error.message : String(error)}`);
915
+ }
916
+ }
917
+
918
+ /**
919
+ * Finds a single document in a collection.
920
+ * @param collectionName - Name of the collection to query
921
+ * @param query - MongoDB query filter
922
+ * @param options - Optional find options (e.g., sort, projection)
923
+ * @returns Single document matching the query or null
924
+ * @throws Error if not initialized or if query fails
925
+ */
926
+ async findOne<T extends Document = Document>(
927
+ collectionName: string,
928
+ query: Filter<T>,
929
+ options?: { sort?: Sort; projection?: Document }
930
+ ): Promise<WithId<T> | null> {
931
+ this.ensureInitialized();
932
+
933
+ try {
934
+ const collection: Collection<T> = this.db!.collection<T>(collectionName);
935
+ let findOptions: any = {};
936
+
937
+ if (options?.sort) {
938
+ findOptions.sort = options.sort;
939
+ }
940
+ if (options?.projection) {
941
+ findOptions.projection = options.projection;
942
+ }
943
+
944
+ const result = await collection.findOne(query, findOptions);
945
+ return result;
946
+ } catch (error) {
947
+ throw new Error(`Failed to find document in collection '${collectionName}': ${error instanceof Error ? error.message : String(error)}`);
948
+ }
949
+ }
950
+
951
+ /**
952
+ * Inserts data into a collection.
953
+ * @param collectionName - Name of the collection to insert into
954
+ * @param data - Single document or array of documents to insert
955
+ * @param options - Optional insert options (e.g., session for transactions)
956
+ * @returns Insert result(s)
957
+ * @throws Error if not initialized or if insert fails
958
+ */
959
+ async insert<T extends Document = Document>(
960
+ collectionName: string,
961
+ data: OptionalUnlessRequiredId<T> | OptionalUnlessRequiredId<T>[],
962
+ options?: { session?: ClientSession }
963
+ ): Promise<any> {
964
+ this.ensureInitialized();
965
+
966
+ try {
967
+ const collection: Collection<T> = this.db!.collection<T>(collectionName);
968
+ const insertOptions = options?.session ? { session: options.session } : {};
969
+
970
+ if (Array.isArray(data)) {
971
+ const result = await collection.insertMany(data as OptionalUnlessRequiredId<T>[], insertOptions);
972
+ return result;
973
+ } else {
974
+ const result = await collection.insertOne(data as OptionalUnlessRequiredId<T>, insertOptions);
975
+ return result;
976
+ }
977
+ } catch (error) {
978
+ throw new Error(`Failed to insert into collection '${collectionName}': ${error instanceof Error ? error.message : String(error)}`);
979
+ }
980
+ }
981
+
982
+ /**
983
+ * Updates records in a collection.
984
+ * @param collectionName - Name of the collection to update
985
+ * @param filter - MongoDB query filter to match documents
986
+ * @param updateData - Update operations to apply
987
+ * @param options - Optional update options (e.g., upsert, multi, session for transactions)
988
+ * @returns Update result
989
+ * @throws Error if not initialized or if update fails
990
+ */
991
+ async update<T extends Document = Document>(
992
+ collectionName: string,
993
+ filter: Filter<T>,
994
+ updateData: UpdateFilter<T>,
995
+ options?: { upsert?: boolean; multi?: boolean; session?: ClientSession }
996
+ ): Promise<any> {
997
+ this.ensureInitialized();
998
+
999
+ try {
1000
+ const collection: Collection<T> = this.db!.collection<T>(collectionName);
1001
+ const updateOptions: any = {};
1002
+ if (options?.upsert !== undefined) updateOptions.upsert = options.upsert;
1003
+ if (options?.session) updateOptions.session = options.session;
1004
+
1005
+ if (options?.multi) {
1006
+ const result = await collection.updateMany(filter, updateData, updateOptions);
1007
+ return result;
1008
+ } else {
1009
+ const result = await collection.updateOne(filter, updateData, updateOptions);
1010
+ return result;
1011
+ }
1012
+ } catch (error) {
1013
+ throw new Error(`Failed to update collection '${collectionName}': ${error instanceof Error ? error.message : String(error)}`);
1014
+ }
1015
+ }
1016
+
1017
+ /**
1018
+ * Deletes documents from a collection.
1019
+ * @param collectionName - Name of the collection to delete from
1020
+ * @param filter - MongoDB query filter to match documents
1021
+ * @param options - Optional delete options (multi: true for deleteMany, false for deleteOne)
1022
+ * @returns Delete result
1023
+ * @throws Error if not initialized or if delete fails
1024
+ */
1025
+ async delete<T extends Document = Document>(
1026
+ collectionName: string,
1027
+ filter: Filter<T>,
1028
+ options?: { multi?: boolean }
1029
+ ): Promise<any> {
1030
+ this.ensureInitialized();
1031
+
1032
+ try {
1033
+ const collection: Collection<T> = this.db!.collection<T>(collectionName);
1034
+
1035
+ if (options?.multi) {
1036
+ const result = await collection.deleteMany(filter);
1037
+ return result;
1038
+ } else {
1039
+ const result = await collection.deleteOne(filter);
1040
+ return result;
1041
+ }
1042
+ } catch (error) {
1043
+ throw new Error(`Failed to delete from collection '${collectionName}': ${error instanceof Error ? error.message : String(error)}`);
1044
+ }
1045
+ }
1046
+
1047
+ /**
1048
+ * Counts documents in a collection matching a query.
1049
+ * @param collectionName - Name of the collection to count
1050
+ * @param query - Optional MongoDB query filter
1051
+ * @returns Number of documents matching the query
1052
+ * @throws Error if not initialized or if count fails
1053
+ */
1054
+ async countDocuments<T extends Document = Document>(
1055
+ collectionName: string,
1056
+ query?: Filter<T>
1057
+ ): Promise<number> {
1058
+ this.ensureInitialized();
1059
+
1060
+ try {
1061
+ const collection: Collection<T> = this.db!.collection<T>(collectionName);
1062
+ const count = await collection.countDocuments(query || {});
1063
+ return count;
1064
+ } catch (error) {
1065
+ throw new Error(`Failed to count documents in collection '${collectionName}': ${error instanceof Error ? error.message : String(error)}`);
1066
+ }
1067
+ }
1068
+
1069
+ /**
1070
+ * Gets an estimated document count for a collection (faster but less accurate).
1071
+ * @param collectionName - Name of the collection to count
1072
+ * @returns Estimated number of documents
1073
+ * @throws Error if not initialized or if count fails
1074
+ */
1075
+ async estimatedDocumentCount(collectionName: string): Promise<number> {
1076
+ this.ensureInitialized();
1077
+
1078
+ try {
1079
+ const collection = this.db!.collection(collectionName);
1080
+ const count = await collection.estimatedDocumentCount();
1081
+ return count;
1082
+ } catch (error) {
1083
+ throw new Error(`Failed to estimate document count in collection '${collectionName}': ${error instanceof Error ? error.message : String(error)}`);
1084
+ }
1085
+ }
1086
+
1087
+ /**
1088
+ * Runs an aggregation pipeline on a collection.
1089
+ * @param collectionName - Name of the collection to aggregate
1090
+ * @param pipeline - Array of aggregation pipeline stages
1091
+ * @returns Array of aggregated results
1092
+ * @throws Error if not initialized or if aggregation fails
1093
+ */
1094
+ async aggregate<T extends Document = Document>(
1095
+ collectionName: string,
1096
+ pipeline: Document[]
1097
+ ): Promise<T[]> {
1098
+ this.ensureInitialized();
1099
+
1100
+ try {
1101
+ const collection: Collection<T> = this.db!.collection<T>(collectionName);
1102
+ const results = await collection.aggregate<T>(pipeline).toArray();
1103
+ return results;
1104
+ } catch (error) {
1105
+ throw new Error(`Failed to aggregate collection '${collectionName}': ${error instanceof Error ? error.message : String(error)}`);
1106
+ }
1107
+ }
1108
+
1109
+ /**
1110
+ * Creates an index on a collection.
1111
+ * @param collectionName - Name of the collection
1112
+ * @param indexSpec - Index specification (field name or index spec object)
1113
+ * @param options - Optional index creation options
1114
+ * @returns Index name
1115
+ * @throws Error if not initialized or if index creation fails
1116
+ */
1117
+ async createIndex(
1118
+ collectionName: string,
1119
+ indexSpec: IndexSpecification,
1120
+ options?: CreateIndexesOptions
1121
+ ): Promise<string> {
1122
+ this.ensureInitialized();
1123
+
1124
+ try {
1125
+ const collection = this.db!.collection(collectionName);
1126
+ const indexName = await collection.createIndex(indexSpec, options);
1127
+ return indexName;
1128
+ } catch (error) {
1129
+ throw new Error(`Failed to create index on collection '${collectionName}': ${error instanceof Error ? error.message : String(error)}`);
1130
+ }
1131
+ }
1132
+
1133
+ /**
1134
+ * Drops an index from a collection.
1135
+ * @param collectionName - Name of the collection
1136
+ * @param indexName - Name of the index to drop
1137
+ * @returns Result object
1138
+ * @throws Error if not initialized or if index drop fails
1139
+ */
1140
+ async dropIndex(collectionName: string, indexName: string): Promise<any> {
1141
+ this.ensureInitialized();
1142
+
1143
+ try {
1144
+ const collection = this.db!.collection(collectionName);
1145
+ const result = await collection.dropIndex(indexName);
1146
+ return result;
1147
+ } catch (error) {
1148
+ throw new Error(`Failed to drop index '${indexName}' from collection '${collectionName}': ${error instanceof Error ? error.message : String(error)}`);
1149
+ }
1150
+ }
1151
+
1152
+ /**
1153
+ * Lists all indexes on a collection.
1154
+ * @param collectionName - Name of the collection
1155
+ * @returns Array of index information
1156
+ * @throws Error if not initialized or if listing fails
1157
+ */
1158
+ async listIndexes(collectionName: string): Promise<Document[]> {
1159
+ this.ensureInitialized();
1160
+
1161
+ try {
1162
+ const collection = this.db!.collection(collectionName);
1163
+ const indexes = await collection.indexes();
1164
+ return indexes;
1165
+ } catch (error) {
1166
+ throw new Error(`Failed to list indexes for collection '${collectionName}': ${error instanceof Error ? error.message : String(error)}`);
1167
+ }
1168
+ }
1169
+
1170
+ /**
1171
+ * Starts a new client session for transactions.
1172
+ * @returns Client session
1173
+ * @throws Error if not initialized
1174
+ */
1175
+ startSession(): ClientSession {
1176
+ this.ensureInitialized();
1177
+
1178
+ if (!this.client) {
1179
+ throw new Error('MongoDB client is not available');
1180
+ }
1181
+
1182
+ return this.client.startSession();
1183
+ }
1184
+
1185
+ /**
1186
+ * Executes a function within a transaction.
1187
+ * @param callback - Function to execute within the transaction
1188
+ * @returns Result of the callback function
1189
+ * @throws Error if not initialized or if transaction fails
1190
+ */
1191
+ async withTransaction<T>(
1192
+ callback: (session: ClientSession) => Promise<T>
1193
+ ): Promise<T> {
1194
+ this.ensureInitialized();
1195
+
1196
+ if (!this.client) {
1197
+ throw new Error('MongoDB client is not available');
1198
+ }
1199
+
1200
+ const session = this.client.startSession();
1201
+
1202
+ try {
1203
+ let result: T;
1204
+ await session.withTransaction(async () => {
1205
+ result = await callback(session);
1206
+ });
1207
+ return result!;
1208
+ } catch (error) {
1209
+ throw new Error(`Transaction failed: ${error instanceof Error ? error.message : String(error)}`);
1210
+ } finally {
1211
+ await session.endSession();
1212
+ }
1213
+ }
1214
+
1215
+ /**
1216
+ * Loads data from a collection using a ref name from the configuration.
1217
+ * @param ref - Application-level reference name (must exist in config.inputs)
1218
+ * @param options - Optional pagination and session options
1219
+ * @returns Array of documents or paginated result
1220
+ * @throws Error if ref not found in config, not initialized, or query fails
1221
+ */
1222
+ async loadByRef<T extends Document = Document>(
1223
+ ref: string,
1224
+ options?: PaginationOptions & { session?: ClientSession }
1225
+ ): Promise<WithId<T>[] | PaginatedResult<T>> {
1226
+ this.ensureInitialized();
1227
+
1228
+ if (!this.config) {
1229
+ throw new Error('Configuration is required for loadByRef. Set config using constructor or useConfig().');
1230
+ }
1231
+
1232
+ const inputConfig = this.config.inputs.find(input => input.ref === ref);
1233
+ if (!inputConfig) {
1234
+ throw new Error(`Ref '${ref}' not found in configuration inputs.`);
1235
+ }
1236
+
1237
+ // Note: loadCollection doesn't support session yet, but we'll pass it through options
1238
+ // For now, we'll use the collection directly with session support
1239
+ const collection: Collection<T> = this.db!.collection<T>(inputConfig.collection);
1240
+ const filter = inputConfig.query || {};
1241
+ const session = options?.session;
1242
+
1243
+ try {
1244
+ const findOptions: any = {};
1245
+ if (session) {
1246
+ findOptions.session = session;
1247
+ }
1248
+
1249
+ let cursor = collection.find(filter, findOptions);
1250
+
1251
+ // Apply sorting if provided
1252
+ if (options?.sort) {
1253
+ cursor = cursor.sort(options.sort);
1254
+ }
1255
+
1256
+ // Apply pagination if provided
1257
+ if (options?.page && options?.limit) {
1258
+ const page = Math.max(1, options.page);
1259
+ const limit = Math.max(1, options.limit);
1260
+ const skip = (page - 1) * limit;
1261
+
1262
+ cursor = cursor.skip(skip).limit(limit);
1263
+
1264
+ // Get total count for pagination
1265
+ const countOptions = session ? { session } : {};
1266
+ const total = await collection.countDocuments(filter, countOptions);
1267
+ const results = await cursor.toArray();
1268
+ const totalPages = Math.ceil(total / limit);
1269
+
1270
+ return {
1271
+ data: results,
1272
+ total,
1273
+ page,
1274
+ limit,
1275
+ totalPages,
1276
+ hasNext: page < totalPages,
1277
+ hasPrev: page > 1,
1278
+ };
1279
+ }
1280
+
1281
+ // Return all results if no pagination
1282
+ const results = await cursor.toArray();
1283
+ return results;
1284
+ } catch (error) {
1285
+ throw new Error(`Failed to load by ref '${ref}': ${error instanceof Error ? error.message : String(error)}`);
1286
+ }
1287
+ }
1288
+
1289
+ /**
1290
+ * Ensures a unique index exists on the _sig field for signature-based deduplication.
1291
+ * @param collectionName - Name of the collection
1292
+ * @param options - Optional configuration
1293
+ * @param options.fieldName - Field name for signature (default: "_sig")
1294
+ * @param options.unique - Whether index should be unique (default: true)
1295
+ * @returns Result object with creation status and index name
1296
+ * @throws Error if not initialized or if index creation fails (with permission hints)
1297
+ */
1298
+ async ensureSignatureIndex(
1299
+ collectionName: string,
1300
+ options?: {
1301
+ fieldName?: string;
1302
+ unique?: boolean;
1303
+ }
1304
+ ): Promise<EnsureSignatureIndexResult> {
1305
+ this.ensureInitialized();
1306
+
1307
+ const fieldName = options?.fieldName || '_sig';
1308
+ const unique = options?.unique !== false; // Default to true
1309
+
1310
+ try {
1311
+ const collection = this.db!.collection(collectionName);
1312
+ const indexes = await collection.indexes();
1313
+
1314
+ // Find existing index on the signature field
1315
+ const existingIndex = indexes.find(idx => {
1316
+ const keys = idx.key as Document;
1317
+ return keys[fieldName] !== undefined;
1318
+ });
1319
+
1320
+ // Check if existing index has correct options
1321
+ if (existingIndex && existingIndex.name) {
1322
+ const isUnique = existingIndex.unique === unique;
1323
+ if (isUnique) {
1324
+ // Index exists with correct options
1325
+ return {
1326
+ created: false,
1327
+ indexName: existingIndex.name,
1328
+ };
1329
+ } else {
1330
+ // Index exists but with wrong options - drop and recreate
1331
+ try {
1332
+ await collection.dropIndex(existingIndex.name);
1333
+ } catch (dropError) {
1334
+ // Ignore drop errors (index might not exist)
1335
+ }
1336
+ }
1337
+ }
1338
+
1339
+ // Create the index
1340
+ const indexSpec: IndexSpecification = { [fieldName]: 1 };
1341
+ const indexOptions: CreateIndexesOptions = { unique };
1342
+ const indexName = await collection.createIndex(indexSpec, indexOptions);
1343
+
1344
+ return {
1345
+ created: true,
1346
+ indexName,
1347
+ };
1348
+ } catch (error) {
1349
+ const errorMessage = error instanceof Error ? error.message : String(error);
1350
+ if (errorMessage.includes('not authorized') || errorMessage.includes('unauthorized')) {
1351
+ throw new Error(
1352
+ `Failed to create signature index on collection '${collectionName}': ${errorMessage}. ` +
1353
+ `This operation requires 'createIndex' privilege.`
1354
+ );
1355
+ }
1356
+ throw new Error(`Failed to ensure signature index on collection '${collectionName}': ${errorMessage}`);
1357
+ }
1358
+ }
1359
+
1360
+ /**
1361
+ * Writes documents to a collection using a ref name from the configuration.
1362
+ * Supports signature-based deduplication and append/replace modes.
1363
+ * @param ref - Application-level reference name (must exist in config.outputs)
1364
+ * @param documents - Array of documents to write
1365
+ * @param options - Optional write options
1366
+ * @param options.session - Transaction session
1367
+ * @param options.ensureIndex - Whether to ensure signature index exists (default: true)
1368
+ * @returns Result object with counts and errors
1369
+ * @throws Error if ref not found in config or not initialized
1370
+ */
1371
+ async writeByRef(
1372
+ ref: string,
1373
+ documents: any[],
1374
+ options?: {
1375
+ session?: ClientSession;
1376
+ ensureIndex?: boolean;
1377
+ }
1378
+ ): Promise<WriteByRefResult> {
1379
+ this.ensureInitialized();
1380
+
1381
+ if (!this.config) {
1382
+ throw new Error('Configuration is required for writeByRef. Set config using constructor or useConfig().');
1383
+ }
1384
+
1385
+ const outputConfig = this.config.outputs.find(output => output.ref === ref);
1386
+ if (!outputConfig) {
1387
+ throw new Error(`Ref '${ref}' not found in configuration outputs.`);
1388
+ }
1389
+
1390
+ const collectionName = outputConfig.collection;
1391
+ const keys = outputConfig.keys;
1392
+ const mode = outputConfig.mode || this.config.output?.mode || 'append';
1393
+ const ensureIndex = options?.ensureIndex !== false; // Default to true
1394
+ const session = options?.session;
1395
+
1396
+ const result: WriteByRefResult = {
1397
+ inserted: 0,
1398
+ updated: 0,
1399
+ errors: [],
1400
+ indexCreated: false,
1401
+ };
1402
+
1403
+ try {
1404
+ const collection = this.db!.collection(collectionName);
1405
+
1406
+ // Handle replace mode: clear collection first
1407
+ if (mode === 'replace') {
1408
+ const deleteOptions = session ? { session } : {};
1409
+ await collection.deleteMany({}, deleteOptions);
1410
+ }
1411
+
1412
+ // If no keys specified, use regular insertMany
1413
+ if (!keys || keys.length === 0) {
1414
+ try {
1415
+ const insertOptions = session ? { session, ordered: false } : { ordered: false };
1416
+ const insertResult = await collection.insertMany(documents, insertOptions);
1417
+ result.inserted = insertResult.insertedCount;
1418
+ } catch (error) {
1419
+ // Aggregate errors from insertMany
1420
+ if (error && typeof error === 'object' && 'writeErrors' in error) {
1421
+ const writeErrors = (error as any).writeErrors || [];
1422
+ for (const writeError of writeErrors) {
1423
+ result.errors.push({
1424
+ index: writeError.index,
1425
+ error: new Error(writeError.errmsg || String(writeError.err)),
1426
+ doc: documents[writeError.index],
1427
+ });
1428
+ }
1429
+ result.inserted = (error as any).insertedCount || 0;
1430
+ } else {
1431
+ // Single error
1432
+ result.errors.push({
1433
+ index: 0,
1434
+ error: error instanceof Error ? error : new Error(String(error)),
1435
+ });
1436
+ }
1437
+ }
1438
+ return result;
1439
+ }
1440
+
1441
+ // Ensure signature index exists
1442
+ if (ensureIndex) {
1443
+ try {
1444
+ const indexResult = await this.ensureSignatureIndex(collectionName, {
1445
+ fieldName: '_sig',
1446
+ unique: true,
1447
+ });
1448
+ result.indexCreated = indexResult.created;
1449
+ } catch (indexError) {
1450
+ result.errors.push({
1451
+ index: -1,
1452
+ error: indexError instanceof Error ? indexError : new Error(String(indexError)),
1453
+ });
1454
+ // Continue even if index creation fails
1455
+ }
1456
+ }
1457
+
1458
+ // Compute signatures and prepare bulk operations
1459
+ const bulkOps: any[] = [];
1460
+ const docWithSigs: Array<{ doc: any; sig: string }> = [];
1461
+
1462
+ for (const doc of documents) {
1463
+ try {
1464
+ const sig = computeSignature(doc, keys);
1465
+ docWithSigs.push({ doc, sig });
1466
+
1467
+ // Add _sig field to document
1468
+ const docWithSig = { ...doc, _sig: sig };
1469
+
1470
+ bulkOps.push({
1471
+ updateOne: {
1472
+ filter: { _sig: sig },
1473
+ update: { $set: docWithSig },
1474
+ upsert: true,
1475
+ },
1476
+ });
1477
+ } catch (error) {
1478
+ result.errors.push({
1479
+ index: docWithSigs.length,
1480
+ error: error instanceof Error ? error : new Error(String(error)),
1481
+ doc,
1482
+ });
1483
+ }
1484
+ }
1485
+
1486
+ // Process in batches of 1000
1487
+ const batchSize = 1000;
1488
+ for (let i = 0; i < bulkOps.length; i += batchSize) {
1489
+ const batch = bulkOps.slice(i, i + batchSize);
1490
+
1491
+ try {
1492
+ const bulkOptions: any = { ordered: false };
1493
+ if (session) {
1494
+ bulkOptions.session = session;
1495
+ }
1496
+
1497
+ const bulkResult = await collection.bulkWrite(batch, bulkOptions);
1498
+
1499
+ result.inserted += bulkResult.upsertedCount || 0;
1500
+ result.updated += bulkResult.modifiedCount || 0;
1501
+
1502
+ // Handle write errors
1503
+ if (bulkResult.hasWriteErrors()) {
1504
+ const writeErrors = bulkResult.getWriteErrors();
1505
+ for (const writeError of writeErrors) {
1506
+ const originalIndex = i + writeError.index;
1507
+ result.errors.push({
1508
+ index: originalIndex,
1509
+ error: new Error(writeError.errmsg || String(writeError.err)),
1510
+ doc: documents[originalIndex],
1511
+ });
1512
+ }
1513
+ }
1514
+ } catch (error) {
1515
+ // Aggregate batch errors
1516
+ if (error && typeof error === 'object' && 'writeErrors' in error) {
1517
+ const writeErrors = (error as any).writeErrors || [];
1518
+ for (const writeError of writeErrors) {
1519
+ const originalIndex = i + writeError.index;
1520
+ result.errors.push({
1521
+ index: originalIndex,
1522
+ error: new Error(writeError.errmsg || String(writeError.err)),
1523
+ doc: documents[originalIndex],
1524
+ });
1525
+ }
1526
+ result.inserted += (error as any).upsertedCount || 0;
1527
+ result.updated += (error as any).modifiedCount || 0;
1528
+ } else {
1529
+ // Batch-level error - mark all documents in batch as errors
1530
+ for (let j = 0; j < batch.length; j++) {
1531
+ result.errors.push({
1532
+ index: i + j,
1533
+ error: error instanceof Error ? error : new Error(String(error)),
1534
+ doc: documents[i + j],
1535
+ });
1536
+ }
1537
+ }
1538
+ }
1539
+ }
1540
+
1541
+ return result;
1542
+ } catch (error) {
1543
+ throw new Error(`Failed to write by ref '${ref}': ${error instanceof Error ? error.message : String(error)}`);
1544
+ }
1545
+ }
1546
+
1547
+ /**
1548
+ * Writes documents to a collection using a ref name and optionally marks a stage as complete.
1549
+ * Combines writeByRef() with progress.complete() for atomic write-and-complete operations.
1550
+ * @param ref - Application-level reference name (must exist in config.outputs)
1551
+ * @param documents - Array of documents to write
1552
+ * @param options - Optional write and completion options
1553
+ * @param options.ensureIndex - Whether to ensure signature index exists (default: true)
1554
+ * @param options.session - Transaction session (if provided, write and complete are atomic)
1555
+ * @param options.complete - Stage completion information (optional)
1556
+ * @returns Result object with write counts, errors, and completion status
1557
+ * @throws Error if ref not found in config or not initialized
1558
+ */
1559
+ async writeStage(
1560
+ ref: string,
1561
+ documents: any[],
1562
+ options?: WriteStageOptions
1563
+ ): Promise<WriteStageResult> {
1564
+ this.ensureInitialized();
1565
+
1566
+ // Write documents using existing writeByRef method
1567
+ const writeResult = await this.writeByRef(ref, documents, {
1568
+ session: options?.session,
1569
+ ensureIndex: options?.ensureIndex,
1570
+ });
1571
+
1572
+ const result: WriteStageResult = {
1573
+ ...writeResult,
1574
+ completed: false,
1575
+ };
1576
+
1577
+ // If complete option is provided, mark stage as complete
1578
+ if (options?.complete) {
1579
+ try {
1580
+ await this.progress.complete(
1581
+ {
1582
+ key: options.complete.key,
1583
+ process: options.complete.process,
1584
+ name: options.complete.name,
1585
+ provider: options.complete.provider,
1586
+ metadata: options.complete.metadata,
1587
+ },
1588
+ { session: options.session }
1589
+ );
1590
+ result.completed = true;
1591
+ } catch (error) {
1592
+ // If completion fails, still return write result but indicate completion failed
1593
+ // Don't throw - let caller decide how to handle partial success
1594
+ result.completed = false;
1595
+ // Could add a completionError field if needed, but keeping it simple for now
1596
+ }
1597
+ }
1598
+
1599
+ return result;
1600
+ }
1601
+
1602
+ /**
1603
+ * Closes the MongoDB connection and cleans up resources.
1604
+ * @throws Error if disconnect fails
1605
+ */
1606
+ async disconnect(): Promise<void> {
1607
+ if (!this.isInitialized || !this.client) {
1608
+ return;
1609
+ }
1610
+
1611
+ try {
1612
+ await this.client.close();
1613
+ this.client = null;
1614
+ this.db = null;
1615
+ this.isInitialized = false;
1616
+ } catch (error) {
1617
+ throw new Error(`Failed to disconnect: ${error instanceof Error ? error.message : String(error)}`);
1618
+ }
1619
+ }
1620
+
1621
+ /**
1622
+ * Ensures the helper is initialized before performing operations.
1623
+ * @throws Error if not initialized
1624
+ * @internal
1625
+ */
1626
+ public ensureInitialized(): void {
1627
+ if (!this.isInitialized || !this.client || !this.db) {
1628
+ throw new Error('SimpleMongoHelper must be initialized before use. Call initialize() first.');
1629
+ }
1630
+ }
1631
+
1632
+ /**
1633
+ * Gets the database instance. Public for use by internal classes.
1634
+ * @internal
1635
+ */
1636
+ public getDb(): Db {
1637
+ if (!this.db) {
1638
+ throw new Error('Database is not initialized. Call initialize() first.');
1639
+ }
1640
+ return this.db;
1641
+ }
1642
+
1643
+ /**
1644
+ * Extracts database name from MongoDB connection string.
1645
+ * @param connectionString - MongoDB connection string
1646
+ * @returns Database name or 'test' as default
1647
+ */
1648
+ private extractDatabaseName(connectionString: string): string {
1649
+ try {
1650
+ const url = new URL(connectionString);
1651
+ const dbName = url.pathname.slice(1); // Remove leading '/'
1652
+ return dbName || 'test';
1653
+ } catch {
1654
+ // If URL parsing fails, try to extract from connection string pattern
1655
+ const match = connectionString.match(/\/([^\/\?]+)(\?|$)/);
1656
+ return match ? match[1] : 'test';
1657
+ }
1658
+ }
1659
+ }
1660
+