fluxor-cloud-db 1.0.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,690 @@
1
+ /**
2
+ * MongoDB implementation of DatabaseAdapter interface
3
+ * This is a template implementation showing how to adapt another database backend
4
+ */
5
+
6
+ import { MongoClient, Db, Collection, Filter, UpdateFilter, WithId } from "mongodb";
7
+ import {
8
+ DatabaseAdapter
9
+ } from "./../contracts/database-adapter";
10
+ import { DatabaseAdapterError } from "./../types/error";
11
+ import { WhereClause, QueryOptions, PaginatedResult, BatchResult, UpdateClause, QueryOperator } from "./../types/query";
12
+
13
+ export interface MongoConfig {
14
+ uri: string;
15
+ dbName: string;
16
+ authSource?: string;
17
+ retryWrites?: boolean;
18
+ w?: "majority" | number;
19
+ }
20
+
21
+ export class MongoService implements DatabaseAdapter {
22
+ private client: MongoClient | undefined;
23
+ private db: Db | undefined;
24
+ private isConnectedFlag = false;
25
+ private config: MongoConfig | undefined;
26
+
27
+ public setConfig(config: MongoConfig): void {
28
+ this.config = config;
29
+ }
30
+
31
+ connect(): Promise<void> {
32
+ if (!this.config) {
33
+ return Promise.reject(new DatabaseAdapterError(
34
+ "No configuration provided. Call setConfig() first.",
35
+ "INVALID_CONFIG"
36
+ ));
37
+ }
38
+
39
+ return Promise.resolve().then(async () => {
40
+ try {
41
+ this.validateConfig(this.config!);
42
+
43
+ this.client = new MongoClient(this.config!.uri, {
44
+ retryWrites: this.config!.retryWrites ?? true,
45
+ w: this.config!.w ?? "majority",
46
+ authSource: this.config!.authSource
47
+ });
48
+
49
+ await this.client.connect();
50
+ this.db = this.client.db(this.config!.dbName);
51
+
52
+ // Verify connection
53
+ await this.db.admin().ping();
54
+ this.isConnectedFlag = true;
55
+ } catch (error) {
56
+ this.isConnectedFlag = false;
57
+ throw this.handleError(error, "connect");
58
+ }
59
+ });
60
+ }
61
+
62
+ async disconnect(): Promise<void> {
63
+ try {
64
+ if (this.client) {
65
+ await this.client.close();
66
+ }
67
+ this.isConnectedFlag = false;
68
+ this.db = undefined;
69
+ this.client = undefined;
70
+ } catch (error) {
71
+ console.warn("Error disconnecting from MongoDB:", error);
72
+ this.isConnectedFlag = false;
73
+ }
74
+ }
75
+
76
+ isConnected(): boolean {
77
+ return this.isConnectedFlag && this.client !== undefined;
78
+ }
79
+
80
+ async healthCheck(): Promise<boolean> {
81
+ try {
82
+ const db = this.getDb();
83
+ await db.admin().ping();
84
+ return true;
85
+ } catch (error) {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ async selectOne<T = Record<string, any>>(
91
+ tableName: string,
92
+ where: WhereClause,
93
+ options?: QueryOptions
94
+ ): Promise<T | null> {
95
+ try {
96
+ const collection = this.getCollection<T>(tableName);
97
+ const filter = this.buildMongoFilter(where);
98
+
99
+ const findOptions: Record<string, unknown> = {};
100
+ if (Array.isArray(options?.projection)) {
101
+ const projection: Record<string, 1 | 0> = {};
102
+ for (const field of options.projection) {
103
+ if (typeof field === "string") {
104
+ projection[field] = 1;
105
+ }
106
+ }
107
+ if (Object.keys(projection).length > 0) {
108
+ findOptions.projection = projection;
109
+ }
110
+ }
111
+
112
+ const result = await collection.findOne(filter, findOptions as Parameters<Collection['findOne']>[1]);
113
+
114
+ if (result === null || result === undefined) {
115
+ return null;
116
+ }
117
+
118
+ return result as unknown as T;
119
+ } catch (error) {
120
+ throw this.handleError(error, "selectOne");
121
+ }
122
+ }
123
+
124
+ async selectMany<T = Record<string, any>>(
125
+ tableName: string,
126
+ where?: WhereClause,
127
+ options?: QueryOptions
128
+ ): Promise<PaginatedResult<T>> {
129
+ try {
130
+ const collection = this.getCollection<T>(tableName);
131
+ const filter = where ? this.buildMongoFilter(where) : {};
132
+
133
+ const findOptions: Record<string, unknown> = {};
134
+
135
+ if (typeof options?.limit === "number" && options.limit > 0) {
136
+ findOptions.limit = options.limit;
137
+ }
138
+
139
+ if (typeof options?.offset === "number" && options.offset >= 0) {
140
+ findOptions.skip = options.offset;
141
+ }
142
+
143
+ if (Array.isArray(options?.orderBy) && options.orderBy.length > 0) {
144
+ const sort: Record<string, 1 | -1> = {};
145
+ for (const order of options.orderBy) {
146
+ if (typeof order === "object" && order !== null && "field" in order && "direction" in order) {
147
+ const field = order.field;
148
+ const direction = order.direction;
149
+ if (typeof field === "string" && (direction === "ASC" || direction === "DESC")) {
150
+ sort[field] = direction === "DESC" ? -1 : 1;
151
+ }
152
+ }
153
+ }
154
+ if (Object.keys(sort).length > 0) {
155
+ findOptions.sort = sort;
156
+ }
157
+ }
158
+
159
+ if (Array.isArray(options?.projection)) {
160
+ const projection: Record<string, 1 | 0> = {};
161
+ for (const field of options.projection) {
162
+ if (typeof field === "string") {
163
+ projection[field] = 1;
164
+ }
165
+ }
166
+ if (Object.keys(projection).length > 0) {
167
+ findOptions.projection = projection;
168
+ }
169
+ }
170
+
171
+ const items = await collection.find(filter, findOptions as Parameters<Collection['find']>[1]).toArray();
172
+ const total = await collection.countDocuments(filter);
173
+
174
+ const offset = typeof options?.offset === "number" ? options.offset : 0;
175
+ const hasMore = offset + items.length < total;
176
+
177
+ return {
178
+ items: items as unknown as T[],
179
+ total,
180
+ hasMore
181
+ };
182
+ } catch (error) {
183
+ throw this.handleError(error, "selectMany");
184
+ }
185
+ }
186
+
187
+ async createOne<T = Record<string, any>>(
188
+ tableName: string,
189
+ data: Record<string, any>
190
+ ): Promise<T> {
191
+ try {
192
+ if (typeof data !== "object" || data === null) {
193
+ throw new DatabaseAdapterError(
194
+ "Data must be an object",
195
+ "INVALID_INPUT"
196
+ );
197
+ }
198
+
199
+ const collection = this.getCollection(tableName);
200
+ const result = await collection.insertOne(data as unknown as WithId<Record<string, unknown>>);
201
+
202
+ return { ...data, _id: result.insertedId } as T;
203
+ } catch (error) {
204
+ if (error instanceof DatabaseAdapterError) {
205
+ throw error;
206
+ }
207
+ throw this.handleError(error, "createOne");
208
+ }
209
+ }
210
+
211
+ async createMany<T = Record<string, any>>(
212
+ tableName: string,
213
+ data: Record<string, any>[],
214
+ options?: { stopOnError?: boolean }
215
+ ): Promise<{ items: T[]; result: BatchResult }> {
216
+ try {
217
+ if (!Array.isArray(data)) {
218
+ throw new DatabaseAdapterError(
219
+ "Data must be an array",
220
+ "INVALID_INPUT"
221
+ );
222
+ }
223
+
224
+ if (data.length === 0) {
225
+ return {
226
+ items: [],
227
+ result: {
228
+ successful: 0,
229
+ failed: 0
230
+ }
231
+ };
232
+ }
233
+
234
+ const collection = this.getCollection(tableName);
235
+ const stopOnError = typeof options?.stopOnError === "boolean" ? options.stopOnError : false;
236
+
237
+ try {
238
+ const result = await collection.insertMany(
239
+ data as unknown as WithId<Record<string, unknown>>[],
240
+ {
241
+ ordered: stopOnError
242
+ }
243
+ );
244
+
245
+ const items = data.map((item, index) => {
246
+ const insertedId = result.insertedIds[index];
247
+ if (insertedId === null || insertedId === undefined) {
248
+ return item as T;
249
+ }
250
+ return {
251
+ ...item,
252
+ _id: insertedId
253
+ } as T;
254
+ });
255
+
256
+ return {
257
+ items,
258
+ result: {
259
+ successful: items.length,
260
+ failed: 0
261
+ }
262
+ };
263
+ } catch (error: unknown) {
264
+ if (stopOnError) {
265
+ throw this.handleError(error, "createMany");
266
+ }
267
+
268
+ // Partial success scenario
269
+ let insertedCount = 0;
270
+
271
+ if (error instanceof Error) {
272
+ const errorMsg = error.message;
273
+ // Try to extract nInserted from error
274
+ if (errorMsg.includes("insertedCount") || errorMsg.includes("nInserted")) {
275
+ const match = errorMsg.match(/\d+/);
276
+ if (match) {
277
+ insertedCount = parseInt(match[0], 10);
278
+ }
279
+ }
280
+ }
281
+
282
+ const failed = data.length - insertedCount;
283
+ const errorMessage = error instanceof Error ? error.message : "Unknown error during batch insert";
284
+
285
+ return {
286
+ items: insertedCount > 0 ? (data.slice(0, insertedCount) as T[]) : [],
287
+ result: {
288
+ successful: insertedCount,
289
+ failed,
290
+ errors: failed > 0 ? [
291
+ {
292
+ index: insertedCount,
293
+ error: errorMessage
294
+ }
295
+ ] : undefined
296
+ }
297
+ };
298
+ }
299
+ } catch (error) {
300
+ throw this.handleError(error, "createMany");
301
+ }
302
+ }
303
+
304
+ async updateOne<T = Record<string, any>>(
305
+ tableName: string,
306
+ update: UpdateClause,
307
+ where: WhereClause
308
+ ): Promise<T> {
309
+ try {
310
+ const collection = this.getCollection<T>(tableName);
311
+ const filter = this.buildMongoFilter(where);
312
+ const updateDoc = this.buildMongoUpdate(update);
313
+
314
+ // Driver v5+ returns the document directly; v4 and below wrap it in { value }.
315
+ // Support both shapes so this works regardless of the installed driver version.
316
+ const raw = await collection.findOneAndUpdate(filter, updateDoc, {
317
+ returnDocument: "after"
318
+ });
319
+
320
+ const doc = raw && typeof raw === "object" && "value" in raw
321
+ ? (raw as any).value
322
+ : raw;
323
+
324
+ if (doc === null || doc === undefined) {
325
+ throw new DatabaseAdapterError(
326
+ "Document not found for update",
327
+ "NOT_FOUND"
328
+ );
329
+ }
330
+
331
+ return doc as unknown as T;
332
+ } catch (error) {
333
+ if (error instanceof DatabaseAdapterError) {
334
+ throw error;
335
+ }
336
+ throw this.handleError(error, "updateOne");
337
+ }
338
+ }
339
+
340
+ async updateMany<T = Record<string, any>>(
341
+ tableName: string,
342
+ update: UpdateClause,
343
+ where?: WhereClause,
344
+ options?: { limit?: number }
345
+ ): Promise<{ items: T[]; result: BatchResult }> {
346
+ try {
347
+ const collection = this.getCollection<T>(tableName);
348
+ const filter = where ? this.buildMongoFilter(where) : {};
349
+ const updateDoc = this.buildMongoUpdate(update);
350
+
351
+ const limit = typeof options?.limit === "number" && options.limit > 0 ? options.limit : undefined;
352
+
353
+ if (limit) {
354
+ const matchingDocs = await collection
355
+ .find(filter)
356
+ .limit(limit)
357
+ .toArray();
358
+
359
+ if (matchingDocs.length === 0) {
360
+ return {
361
+ items: [],
362
+ result: { successful: 0, failed: 0 }
363
+ };
364
+ }
365
+
366
+ const ids = matchingDocs
367
+ .map((doc) => {
368
+ const docWithId = doc as WithId<Record<string, unknown>>;
369
+ return docWithId._id;
370
+ })
371
+ .filter((id) => id !== null && id !== undefined);
372
+
373
+ if (ids.length === 0) {
374
+ return {
375
+ items: [],
376
+ result: { successful: 0, failed: 0 }
377
+ };
378
+ }
379
+
380
+ const idFilter: Filter<WithId<Record<string, unknown>>> = { _id: { $in: ids } };
381
+ const updateResult = await collection.updateMany(idFilter, updateDoc);
382
+
383
+ return {
384
+ items: matchingDocs as unknown as T[],
385
+ result: {
386
+ successful: updateResult.modifiedCount,
387
+ failed: 0
388
+ }
389
+ };
390
+ } else {
391
+ const updateResult = await collection.updateMany(filter, updateDoc);
392
+ const updatedDocs = await collection.find(filter).toArray();
393
+
394
+ return {
395
+ items: updatedDocs as unknown as T[],
396
+ result: {
397
+ successful: updateResult.modifiedCount,
398
+ failed: 0
399
+ }
400
+ };
401
+ }
402
+ } catch (error) {
403
+ throw this.handleError(error, "updateMany");
404
+ }
405
+ }
406
+
407
+ async deleteOne(
408
+ tableName: string,
409
+ where: WhereClause
410
+ ): Promise<{ success: boolean; deletedCount: number }> {
411
+ try {
412
+ const collection = this.getCollection(tableName);
413
+ const filter = this.buildMongoFilter(where);
414
+
415
+ const result = await collection.deleteOne(filter);
416
+
417
+ return {
418
+ success: result.deletedCount > 0,
419
+ deletedCount: result.deletedCount
420
+ };
421
+ } catch (error) {
422
+ throw this.handleError(error, "deleteOne");
423
+ }
424
+ }
425
+
426
+ async deleteMany(
427
+ tableName: string,
428
+ where?: WhereClause,
429
+ options?: { limit?: number }
430
+ ): Promise<{ success: boolean; deletedCount: number; result: BatchResult }> {
431
+ try {
432
+ const collection = this.getCollection(tableName);
433
+ const filter = where ? this.buildMongoFilter(where) : {};
434
+
435
+ let deletedCount = 0;
436
+
437
+ if (typeof options?.limit === "number" && options.limit > 0) {
438
+ const docsToDelete = await collection
439
+ .find(filter)
440
+ .limit(options.limit)
441
+ .toArray();
442
+
443
+ if (docsToDelete.length === 0) {
444
+ return {
445
+ success: true,
446
+ deletedCount: 0,
447
+ result: {
448
+ successful: 0,
449
+ failed: 0
450
+ }
451
+ };
452
+ }
453
+
454
+ const ids = docsToDelete
455
+ .map((doc) => {
456
+ const docWithId = doc as WithId<Record<string, unknown>>;
457
+ return docWithId._id;
458
+ })
459
+ .filter((id) => id !== null && id !== undefined);
460
+
461
+ if (ids.length === 0) {
462
+ return {
463
+ success: true,
464
+ deletedCount: 0,
465
+ result: {
466
+ successful: 0,
467
+ failed: 0
468
+ }
469
+ };
470
+ }
471
+
472
+ const idFilter: Filter<WithId<Record<string, unknown>>> = { _id: { $in: ids } };
473
+ const result = await collection.deleteMany(idFilter);
474
+ deletedCount = result.deletedCount;
475
+ } else {
476
+ const result = await collection.deleteMany(filter);
477
+ deletedCount = result.deletedCount;
478
+ }
479
+
480
+ return {
481
+ success: true,
482
+ deletedCount,
483
+ result: {
484
+ successful: deletedCount,
485
+ failed: 0
486
+ }
487
+ };
488
+ } catch (error) {
489
+ throw this.handleError(error, "deleteMany");
490
+ }
491
+ }
492
+
493
+ async executeRaw<T = any>(query: string, params?: Record<string, any>): Promise<T> {
494
+ // This could execute an aggregation pipeline or raw MongoDB query
495
+ throw new DatabaseAdapterError(
496
+ "executeRaw not fully implemented for this example",
497
+ "NOT_IMPLEMENTED"
498
+ );
499
+ }
500
+
501
+ // ============ Private Helpers ============
502
+
503
+ private validateConfig(config: MongoConfig): void {
504
+ if (!config || !config.uri || !config.dbName) {
505
+ throw new DatabaseAdapterError(
506
+ "MongoDB configuration requires uri and dbName",
507
+ "INVALID_CONFIG"
508
+ );
509
+ }
510
+ }
511
+
512
+ private getDb(): Db {
513
+ if (!this.isConnectedFlag || !this.db) {
514
+ throw new DatabaseAdapterError(
515
+ "MongoDB client is not connected. Call connect() first.",
516
+ "NOT_CONNECTED"
517
+ );
518
+ }
519
+ return this.db;
520
+ }
521
+
522
+ private getCollection<T = Record<string, any>>(tableName: string): Collection<WithId<Record<string, unknown>>> {
523
+ if (typeof tableName !== "string" || !tableName) {
524
+ throw new DatabaseAdapterError(
525
+ "Table name must be a non-empty string",
526
+ "INVALID_TABLE_NAME"
527
+ );
528
+ }
529
+ return this.getDb().collection<WithId<Record<string, unknown>>>(tableName);
530
+ }
531
+
532
+ private buildMongoFilter(where: WhereClause): Filter<WithId<Record<string, unknown>>> {
533
+ const filter: Filter<WithId<Record<string, unknown>>> = {};
534
+
535
+ if (typeof where !== "object" || where === null) {
536
+ return filter;
537
+ }
538
+
539
+ for (const [field, condition] of Object.entries(where)) {
540
+ if (!field || typeof field !== "string") {
541
+ continue;
542
+ }
543
+
544
+ if (condition === null || condition === undefined) {
545
+ continue;
546
+ }
547
+
548
+ if (typeof condition === "object" && "operator" in condition) {
549
+ const condObj = condition as Record<string, unknown>;
550
+ const operator = condObj["operator"];
551
+ const value = condObj["value"];
552
+
553
+ if (typeof operator !== "string" || !this.isValidOperator(operator)) {
554
+ continue;
555
+ }
556
+
557
+ if (value === null || value === undefined) {
558
+ continue;
559
+ }
560
+
561
+ const op = operator as QueryOperator;
562
+
563
+ switch (op) {
564
+ case "=":
565
+ filter[field] = value;
566
+ break;
567
+ case "!=":
568
+ filter[field] = { $ne: value };
569
+ break;
570
+ case ">":
571
+ filter[field] = { $gt: value };
572
+ break;
573
+ case ">=":
574
+ filter[field] = { $gte: value };
575
+ break;
576
+ case "<":
577
+ filter[field] = { $lt: value };
578
+ break;
579
+ case "<=":
580
+ filter[field] = { $lte: value };
581
+ break;
582
+ case "IN":
583
+ if (Array.isArray(value)) {
584
+ filter[field] = { $in: value };
585
+ } else {
586
+ filter[field] = { $in: [value] };
587
+ }
588
+ break;
589
+ case "NOT_IN":
590
+ if (Array.isArray(value)) {
591
+ filter[field] = { $nin: value };
592
+ } else {
593
+ filter[field] = { $nin: [value] };
594
+ }
595
+ break;
596
+ case "BETWEEN":
597
+ if (Array.isArray(value) && value.length === 2) {
598
+ const [min, max] = value;
599
+ if (min !== null && min !== undefined && max !== null && max !== undefined) {
600
+ filter[field] = { $gte: min, $lte: max };
601
+ }
602
+ }
603
+ break;
604
+ case "LIKE":
605
+ if (typeof value === "string") {
606
+ filter[field] = { $regex: value, $options: "i" };
607
+ }
608
+ break;
609
+ case "BEGINS_WITH":
610
+ if (typeof value === "string") {
611
+ filter[field] = { $regex: `^${value}`, $options: "i" };
612
+ }
613
+ break;
614
+ case "EXISTS":
615
+ if (typeof value === "boolean") {
616
+ filter[field] = { $exists: value };
617
+ }
618
+ break;
619
+ }
620
+ } else {
621
+ filter[field] = condition;
622
+ }
623
+ }
624
+
625
+ return filter;
626
+ }
627
+
628
+ private isValidOperator(op: string): op is QueryOperator {
629
+ const validOperators: QueryOperator[] = ["=", "!=", ">", ">=", "<", "<=", "IN", "NOT_IN", "BETWEEN", "LIKE", "BEGINS_WITH", "EXISTS"];
630
+ return validOperators.includes(op as QueryOperator);
631
+ }
632
+
633
+ private buildMongoUpdate(update: UpdateClause): UpdateFilter<WithId<Record<string, unknown>>> {
634
+ const mongoUpdate: Record<string, Record<string, unknown>> = {};
635
+ const setFields: Record<string, unknown> = {};
636
+ const incrementFields: Record<string, unknown> = {};
637
+
638
+ if (typeof update !== "object" || update === null) {
639
+ return mongoUpdate as UpdateFilter<WithId<Record<string, unknown>>>;
640
+ }
641
+
642
+ for (const [field, value] of Object.entries(update)) {
643
+ if (!field || typeof field !== "string") {
644
+ continue;
645
+ }
646
+
647
+ if (value === null || value === undefined) {
648
+ continue;
649
+ }
650
+
651
+ if (typeof value === "object" && "$increment" in value) {
652
+ const val = value as Record<string, unknown>;
653
+ if (typeof val["$increment"] === "number") {
654
+ incrementFields[field] = val["$increment"];
655
+ }
656
+ } else {
657
+ setFields[field] = value;
658
+ }
659
+ }
660
+
661
+ if (Object.keys(setFields).length > 0) {
662
+ mongoUpdate.$set = setFields;
663
+ }
664
+
665
+ if (Object.keys(incrementFields).length > 0) {
666
+ mongoUpdate.$inc = incrementFields;
667
+ }
668
+
669
+ return mongoUpdate as UpdateFilter<WithId<Record<string, unknown>>>;
670
+ }
671
+
672
+ private handleError(error: unknown, operation: string): DatabaseAdapterError {
673
+ if (error instanceof DatabaseAdapterError) {
674
+ return error;
675
+ }
676
+
677
+ if (error instanceof Error) {
678
+ return new DatabaseAdapterError(
679
+ `${operation} failed: ${error.message}`,
680
+ "OPERATION_FAILED",
681
+ { originalError: error.message }
682
+ );
683
+ }
684
+
685
+ return new DatabaseAdapterError(
686
+ `${operation} failed: Unknown error`,
687
+ "OPERATION_FAILED"
688
+ );
689
+ }
690
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Base error type for database operations
3
+ */
4
+ export class DatabaseAdapterError extends Error {
5
+ constructor(
6
+ message: string,
7
+ public readonly code: string,
8
+ public readonly details?: Record<string, any>
9
+ ) {
10
+ super(message);
11
+ this.name = "DatabaseAdapterError";
12
+ }
13
+ }