@objectql/driver-mongo 4.0.2 → 4.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { Data, Driver as DriverSpec } from '@objectstack/spec';
1
+ import { Data, System as SystemSpec } from '@objectstack/spec';
2
2
  type QueryAST = Data.QueryAST;
3
3
  type SortNode = Data.SortNode;
4
- type DriverInterface = DriverSpec.DriverInterface;
4
+ type DriverInterface = Data.DriverInterface;
5
5
  /**
6
6
  * ObjectQL
7
7
  * Copyright (c) 2026-present ObjectStack Inc.
@@ -11,7 +11,24 @@ type DriverInterface = DriverSpec.DriverInterface;
11
11
  */
12
12
 
13
13
  import { Driver } from '@objectql/types';
14
- import { MongoClient, Db, Filter, ObjectId, FindOptions } from 'mongodb';
14
+ import { MongoClient, Db, Filter, ObjectId, FindOptions, FindOneAndUpdateOptions, UpdateFilter, ChangeStream, ChangeStreamDocument } from 'mongodb';
15
+
16
+ /**
17
+ * Change stream event handler callback
18
+ */
19
+ export type ChangeStreamHandler = (change: ChangeStreamDocument) => void | Promise<void>;
20
+
21
+ /**
22
+ * Change stream options
23
+ */
24
+ export interface ChangeStreamOptions {
25
+ /** Filter for specific operation types (insert, update, delete, replace) */
26
+ operationTypes?: ('insert' | 'update' | 'delete' | 'replace')[];
27
+ /** Full document lookup for update operations */
28
+ fullDocument?: 'updateLookup' | 'whenAvailable' | 'required';
29
+ /** Pipeline to filter change events */
30
+ pipeline?: any[];
31
+ }
15
32
 
16
33
  /**
17
34
  * Command interface for executeCommand method
@@ -68,11 +85,13 @@ export class MongoDriver implements Driver {
68
85
  private db?: Db;
69
86
  private config: any;
70
87
  private connected: Promise<void>;
88
+ private changeStreams: Map<string, ChangeStream>;
71
89
 
72
90
  constructor(config: { url: string, dbName?: string }) {
73
91
  this.config = config;
74
92
  this.client = new MongoClient(config.url);
75
93
  this.connected = this.internalConnect();
94
+ this.changeStreams = new Map();
76
95
  }
77
96
 
78
97
  /**
@@ -391,7 +410,22 @@ export class MongoDriver implements Driver {
391
410
  mongoDoc._id = new ObjectId().toHexString();
392
411
  }
393
412
 
394
- const result = await collection.insertOne(mongoDoc);
413
+ // Add timestamps if not already present
414
+ const now = new Date().toISOString();
415
+ if (!mongoDoc.created_at) {
416
+ mongoDoc.created_at = now;
417
+ }
418
+ if (!mongoDoc.updated_at) {
419
+ mongoDoc.updated_at = now;
420
+ }
421
+
422
+ // Pass session for transactional operations only if it exists
423
+ // Support both 'transaction' and 'session' option keys for compatibility
424
+ const session = options?.transaction || options?.session;
425
+ const result = session
426
+ ? await collection.insertOne(mongoDoc, { session })
427
+ : await collection.insertOne(mongoDoc);
428
+
395
429
  // Return API format document (convert _id to id)
396
430
  return this.mapFromMongo({ ...mongoDoc, _id: result.insertedId });
397
431
  }
@@ -401,19 +435,54 @@ export class MongoDriver implements Driver {
401
435
 
402
436
  // Map API document (id) to MongoDB document (_id) for update data
403
437
  // But we should not allow updating the _id field itself
404
- const { id: _ignoredId, ...updateData } = data; // intentionally ignore id to prevent updating primary key
438
+ const { id: _ignoredId, created_at: _ignoredCreatedAt, ...updateData } = data; // intentionally ignore id and created_at to prevent updating them
405
439
 
406
440
  // Handle atomic operators if present
407
441
  const isAtomic = Object.keys(updateData).some(k => k.startsWith('$'));
408
- const update = isAtomic ? updateData : { $set: updateData };
442
+
443
+ // Build the update object with updated_at timestamp
444
+ let update: UpdateFilter<any>;
445
+ if (isAtomic) {
446
+ // When using atomic operators, add updated_at to $set
447
+ // The spread is safe because id and created_at were already removed via destructuring
448
+ update = { ...updateData } as any;
449
+ if (!update.$set) {
450
+ update.$set = {};
451
+ }
452
+ update.$set.updated_at = new Date().toISOString();
453
+ } else {
454
+ // For regular updates, add updated_at to the data
455
+ updateData.updated_at = new Date().toISOString();
456
+ update = { $set: updateData };
457
+ }
409
458
 
410
- const result = await collection.updateOne({ _id: this.normalizeId(id) }, update);
411
- return result.modifiedCount; // or return updated document?
459
+ // Use findOneAndUpdate to return the updated document
460
+ const mongoOptions: FindOneAndUpdateOptions = { returnDocument: 'after' };
461
+ // Support both 'transaction' and 'session' option keys for compatibility
462
+ const session = options?.transaction || options?.session;
463
+ if (session) {
464
+ mongoOptions.session = session;
465
+ }
466
+
467
+ const result = await collection.findOneAndUpdate(
468
+ { _id: this.normalizeId(id) },
469
+ update,
470
+ mongoOptions
471
+ );
472
+
473
+ // Return API format document (convert _id to id)
474
+ // findOneAndUpdate returns an object with a 'value' property containing the document
475
+ return this.mapFromMongo(result?.value);
412
476
  }
413
477
 
414
478
  async delete(objectName: string, id: string | number, options?: any) {
415
479
  const collection = await this.getCollection(objectName);
416
- const result = await collection.deleteOne({ _id: this.normalizeId(id) });
480
+ // Pass session for transactional operations only if it exists
481
+ // Support both 'transaction' and 'session' option keys for compatibility
482
+ const session = options?.transaction || options?.session;
483
+ const result = session
484
+ ? await collection.deleteOne({ _id: this.normalizeId(id) }, { session })
485
+ : await collection.deleteOne({ _id: this.normalizeId(id) });
417
486
  return result.deletedCount;
418
487
  }
419
488
 
@@ -441,7 +510,11 @@ export class MongoDriver implements Driver {
441
510
  }
442
511
  return mongoDoc;
443
512
  });
444
- const result = await collection.insertMany(mongoDocs);
513
+ // Support both 'transaction' and 'session' option keys for compatibility
514
+ const session = options?.transaction || options?.session;
515
+ const result = session
516
+ ? await collection.insertMany(mongoDocs, { session })
517
+ : await collection.insertMany(mongoDocs);
445
518
  // Return API format (convert _id to id)
446
519
  return Object.values(result.insertedIds).map(id => ({ id }));
447
520
  }
@@ -456,15 +529,23 @@ export class MongoDriver implements Driver {
456
529
  const isAtomic = Object.keys(updateData).some(k => k.startsWith('$'));
457
530
  const update = isAtomic ? updateData : { $set: updateData };
458
531
 
459
- const result = await collection.updateMany(filter, update);
460
- return result.modifiedCount;
532
+ // Support both 'transaction' and 'session' option keys for compatibility
533
+ const session = options?.transaction || options?.session;
534
+ const result = session
535
+ ? await collection.updateMany(filter, update, { session })
536
+ : await collection.updateMany(filter, update);
537
+ return { modifiedCount: result.modifiedCount };
461
538
  }
462
539
 
463
540
  async deleteMany(objectName: string, filters: any, options?: any): Promise<any> {
464
541
  const collection = await this.getCollection(objectName);
465
542
  const filter = this.mapFilters(filters);
466
- const result = await collection.deleteMany(filter);
467
- return result.deletedCount;
543
+ // Support both 'transaction' and 'session' option keys for compatibility
544
+ const session = options?.transaction || options?.session;
545
+ const result = session
546
+ ? await collection.deleteMany(filter, { session })
547
+ : await collection.deleteMany(filter);
548
+ return { deletedCount: result.deletedCount };
468
549
  }
469
550
 
470
551
  async aggregate(objectName: string, pipeline: any[], options?: any): Promise<any[]> {
@@ -474,12 +555,237 @@ export class MongoDriver implements Driver {
474
555
  return this.mapFromMongoArray(results);
475
556
  }
476
557
 
558
+ /**
559
+ * Get distinct values for a field
560
+ * @param objectName - The collection name
561
+ * @param field - The field to get distinct values from
562
+ * @param filters - Optional filters to apply
563
+ * @param options - Optional query options
564
+ * @returns Array of distinct values
565
+ */
566
+ async distinct(objectName: string, field: string, filters?: any, options?: any): Promise<any[]> {
567
+ const collection = await this.getCollection(objectName);
568
+
569
+ // Convert ObjectQL filters to MongoDB query format
570
+ const filter = filters ? this.mapFilters(filters) : {};
571
+
572
+ // Use MongoDB's native distinct method
573
+ const results = await collection.distinct(field, filter);
574
+
575
+ return results;
576
+ }
577
+
578
+ /**
579
+ * Find one document and update it atomically
580
+ * @param objectName - The collection name
581
+ * @param filters - Query filters to find the document
582
+ * @param update - Update operations to apply
583
+ * @param options - Optional query options (e.g., returnDocument, upsert)
584
+ * @returns The updated document
585
+ *
586
+ * @example
587
+ * // Find and update with returnDocument: 'after' to get the updated doc
588
+ * const updated = await driver.findOneAndUpdate('users',
589
+ * { email: 'user@example.com' },
590
+ * { $set: { status: 'active' } },
591
+ * { returnDocument: 'after' }
592
+ * );
593
+ */
594
+ async findOneAndUpdate(objectName: string, filters: any, update: any, options?: any): Promise<any> {
595
+ const collection = await this.getCollection(objectName);
596
+
597
+ // Convert ObjectQL filters to MongoDB query format
598
+ const filter = this.mapFilters(filters);
599
+
600
+ // MongoDB findOneAndUpdate options
601
+ const mongoOptions: any = {
602
+ returnDocument: options?.returnDocument || 'after', // 'before' or 'after'
603
+ upsert: options?.upsert || false
604
+ };
605
+
606
+ // Execute the atomic find and update
607
+ const result = await collection.findOneAndUpdate(filter, update, mongoOptions);
608
+
609
+ // Map MongoDB document to API format (convert _id to id)
610
+ // MongoDB driver v5+ returns { value: document, ok: number }
611
+ // Older versions (v4) return the document directly
612
+ // We handle both for backward compatibility
613
+ const doc = result?.value !== undefined ? result.value : result;
614
+ return doc ? this.mapFromMongo(doc) : null;
615
+ }
616
+
617
+ // ========== Transaction Support ==========
618
+
619
+ /**
620
+ * Begin a new transaction session
621
+ *
622
+ * @returns MongoDB ClientSession that can be used for transactional operations
623
+ *
624
+ * @example
625
+ * const session = await driver.beginTransaction();
626
+ * try {
627
+ * await driver.create('users', { name: 'Alice' }, { session });
628
+ * await driver.create('orders', { userId: 'alice' }, { session });
629
+ * await driver.commitTransaction(session);
630
+ * } catch (error) {
631
+ * await driver.rollbackTransaction(session);
632
+ * throw error;
633
+ * }
634
+ */
635
+ async beginTransaction(): Promise<any> {
636
+ await this.connected;
637
+ const session = this.client.startSession();
638
+ session.startTransaction();
639
+ return session;
640
+ }
641
+
642
+ /**
643
+ * Commit a transaction
644
+ *
645
+ * @param transaction - MongoDB ClientSession returned by beginTransaction()
646
+ *
647
+ * @example
648
+ * const session = await driver.beginTransaction();
649
+ * // ... perform operations with { session } in options
650
+ * await driver.commitTransaction(session);
651
+ */
652
+ async commitTransaction(transaction: any): Promise<void> {
653
+ if (!transaction || typeof transaction.commitTransaction !== 'function') {
654
+ throw new Error('Invalid transaction object. Must be a MongoDB ClientSession.');
655
+ }
656
+
657
+ try {
658
+ await transaction.commitTransaction();
659
+ } finally {
660
+ await transaction.endSession();
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Rollback a transaction
666
+ *
667
+ * @param transaction - MongoDB ClientSession returned by beginTransaction()
668
+ *
669
+ * @example
670
+ * const session = await driver.beginTransaction();
671
+ * try {
672
+ * // ... perform operations
673
+ * await driver.commitTransaction(session);
674
+ * } catch (error) {
675
+ * await driver.rollbackTransaction(session);
676
+ * }
677
+ */
678
+ async rollbackTransaction(transaction: any): Promise<void> {
679
+ if (!transaction || typeof transaction.abortTransaction !== 'function') {
680
+ throw new Error('Invalid transaction object. Must be a MongoDB ClientSession.');
681
+ }
682
+
683
+ try {
684
+ await transaction.abortTransaction();
685
+ } finally {
686
+ await transaction.endSession();
687
+ }
688
+ }
689
+
477
690
  async disconnect() {
691
+ // Close all active change streams
692
+ for (const [streamId, stream] of this.changeStreams.entries()) {
693
+ await stream.close();
694
+ }
695
+ this.changeStreams.clear();
696
+
697
+ // Close the MongoDB client
478
698
  if (this.client) {
479
699
  await this.client.close();
480
700
  }
481
701
  }
482
702
 
703
+ /**
704
+ * Watch for changes in a collection using MongoDB Change Streams
705
+ * @param objectName - The collection name to watch
706
+ * @param handler - Callback function to handle change events
707
+ * @param options - Optional change stream configuration
708
+ * @returns Stream ID that can be used to close the stream later
709
+ *
710
+ * @example
711
+ * const streamId = await driver.watch('users', async (change) => {
712
+ * console.log('Change detected:', change.operationType);
713
+ * if (change.operationType === 'insert') {
714
+ * console.log('New document:', change.fullDocument);
715
+ * }
716
+ * }, {
717
+ * operationTypes: ['insert', 'update'],
718
+ * fullDocument: 'updateLookup'
719
+ * });
720
+ *
721
+ * // Later, to stop watching:
722
+ * await driver.unwatchChangeStream(streamId);
723
+ */
724
+ async watch(objectName: string, handler: ChangeStreamHandler, options?: ChangeStreamOptions): Promise<string> {
725
+ const collection = await this.getCollection(objectName);
726
+
727
+ // Build change stream pipeline
728
+ const pipeline: any[] = options?.pipeline || [];
729
+
730
+ // Add operation type filter if specified
731
+ if (options?.operationTypes && options.operationTypes.length > 0) {
732
+ pipeline.unshift({
733
+ $match: {
734
+ operationType: { $in: options.operationTypes }
735
+ }
736
+ });
737
+ }
738
+
739
+ // Configure change stream options
740
+ const streamOptions: any = {};
741
+ if (options?.fullDocument) {
742
+ streamOptions.fullDocument = options.fullDocument;
743
+ }
744
+
745
+ // Create the change stream
746
+ const changeStream = collection.watch(pipeline, streamOptions);
747
+
748
+ // Generate unique stream ID
749
+ const streamId = `${objectName}_${Date.now()}_${Math.random().toString(36).substring(7)}`;
750
+
751
+ // Store the stream
752
+ this.changeStreams.set(streamId, changeStream);
753
+
754
+ // Handle change events
755
+ changeStream.on('change', async (change) => {
756
+ try {
757
+ await handler(change);
758
+ } catch (error) {
759
+ console.error(`[MongoDriver] Error in change stream handler for ${objectName}:`, error);
760
+ }
761
+ });
762
+
763
+ changeStream.on('error', (error) => {
764
+ console.error(`[MongoDriver] Change stream error for ${objectName}:`, error);
765
+ });
766
+
767
+ return streamId;
768
+ }
769
+
770
+ /**
771
+ * Stop watching a change stream
772
+ * @param streamId - The stream ID returned by watch()
773
+ */
774
+ async unwatchChangeStream(streamId: string): Promise<void> {
775
+ const stream = this.changeStreams.get(streamId);
776
+ if (stream) {
777
+ await stream.close();
778
+ this.changeStreams.delete(streamId);
779
+ }
780
+ }
781
+
782
+ /**
783
+ * Get all active change stream IDs
784
+ */
785
+ getActiveChangeStreams(): string[] {
786
+ return Array.from(this.changeStreams.keys());
787
+ }
788
+
483
789
  /**
484
790
  * Execute a query using QueryAST (DriverInterface v4.0 method)
485
791
  *
@@ -22,6 +22,7 @@ const mockCollection = {
22
22
  insertedCount: 2
23
23
  }),
24
24
  updateOne: jest.fn().mockResolvedValue({ modifiedCount: 1 }),
25
+ findOneAndUpdate: jest.fn().mockResolvedValue({ value: { _id: '123', name: 'Updated' } }),
25
26
  deleteOne: jest.fn().mockResolvedValue({ deletedCount: 1 }),
26
27
  countDocuments: jest.fn().mockResolvedValue(10)
27
28
  };
@@ -437,20 +438,19 @@ describe('MongoDriver', () => {
437
438
  data: { name: 'Updated User' }
438
439
  };
439
440
 
440
- mockCollection.updateOne.mockResolvedValue({
441
- modifiedCount: 1,
442
- acknowledged: true
443
- } as any);
444
- mockCollection.findOne.mockResolvedValue({
445
- _id: '123',
446
- name: 'Updated User'
441
+ mockCollection.findOneAndUpdate.mockResolvedValue({
442
+ value: {
443
+ _id: '123',
444
+ name: 'Updated User',
445
+ updated_at: new Date().toISOString()
446
+ }
447
447
  });
448
448
 
449
449
  const result = await driver.executeCommand(command);
450
450
 
451
451
  expect(result.success).toBe(true);
452
452
  expect(result.affected).toBe(1);
453
- expect(mockCollection.updateOne).toHaveBeenCalled();
453
+ expect(mockCollection.findOneAndUpdate).toHaveBeenCalled();
454
454
  });
455
455
 
456
456
  it('should execute delete command', async () => {
@@ -504,11 +504,13 @@ describe('MongoDriver', () => {
504
504
  ]
505
505
  };
506
506
 
507
- mockCollection.updateOne.mockResolvedValue({
508
- modifiedCount: 1,
509
- acknowledged: true
510
- } as any);
511
- mockCollection.findOne.mockResolvedValue({ _id: '1', name: 'Updated 1' });
507
+ mockCollection.findOneAndUpdate.mockResolvedValue({
508
+ value: {
509
+ _id: '1',
510
+ name: 'Updated 1',
511
+ updated_at: new Date().toISOString()
512
+ }
513
+ });
512
514
 
513
515
  const result = await driver.executeCommand(command);
514
516