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.
- package/IMPROVEMENT_PLAN.md +223 -0
- package/PROVIDER_INSTRUCTIONS.md +460 -0
- package/README.md +1144 -0
- package/dist/simpleMongoHelper.d.ts +366 -0
- package/dist/simpleMongoHelper.d.ts.map +1 -0
- package/dist/simpleMongoHelper.js +1333 -0
- package/dist/simpleMongoHelper.js.map +1 -0
- package/dist/test.d.ts +2 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +179 -0
- package/dist/test.js.map +1 -0
- package/package.json +41 -0
- package/src/simpleMongoHelper.ts +1660 -0
- package/src/test.ts +209 -0
- package/tsconfig.json +21 -0
|
@@ -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
|
+
|