nx-mongo 3.5.0 → 3.8.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # nx-mongo
2
2
 
3
- **Version:** 3.5.0
3
+ **Version:** 3.8.0
4
4
 
5
5
  A lightweight, feature-rich MongoDB helper library for Node.js and TypeScript. Provides a simple, intuitive API for common MongoDB operations with built-in retry logic, pagination, transactions, config-driven ref mapping, and signature-based deduplication.
6
6
 
@@ -9,6 +9,7 @@ A lightweight, feature-rich MongoDB helper library for Node.js and TypeScript. P
9
9
  - ✅ **Simple API** - Easy-to-use methods for common MongoDB operations
10
10
  - ✅ **TypeScript Support** - Full TypeScript support with type safety
11
11
  - ✅ **Connection Retry** - Automatic retry with exponential backoff
12
+ - ✅ **Automatic Cleanup** - Connections automatically close on app exit (SIGINT, SIGTERM, etc.)
12
13
  - ✅ **Pagination** - Built-in pagination support with metadata
13
14
  - ✅ **Transactions** - Full transaction support for multi-operation consistency
14
15
  - ✅ **Aggregation** - Complete aggregation pipeline support
@@ -30,24 +31,39 @@ npm install nx-mongo
30
31
  ```typescript
31
32
  import { SimpleMongoHelper } from 'nx-mongo';
32
33
 
33
- const helper = new SimpleMongoHelper('mongodb://localhost:27017/my-database');
34
+ // Connection string: database name is ignored/stripped automatically
35
+ // Use base connection string: mongodb://localhost:27017/
36
+ const helper = new SimpleMongoHelper('mongodb://localhost:27017/');
34
37
 
35
38
  // Initialize connection
36
39
  await helper.initialize();
37
40
 
38
- // Insert a document
41
+ // Insert a document (defaults to 'admin' database)
39
42
  await helper.insert('users', {
40
43
  name: 'John Doe',
41
44
  email: 'john@example.com',
42
45
  age: 30
43
46
  });
44
47
 
45
- // Find documents
48
+ // Insert into a specific database
49
+ await helper.insert('users', {
50
+ name: 'Jane Doe',
51
+ email: 'jane@example.com',
52
+ age: 28
53
+ }, {}, 'mydb'); // Specify database name
54
+
55
+ // Find documents from 'admin' database (default)
46
56
  const users = await helper.loadCollection('users');
47
57
 
58
+ // Find documents from specific database
59
+ const mydbUsers = await helper.loadCollection('users', {}, undefined, 'mydb');
60
+
48
61
  // Find one document
49
62
  const user = await helper.findOne('users', { email: 'john@example.com' });
50
63
 
64
+ // Find from specific database
65
+ const mydbUser = await helper.findOne('users', { email: 'jane@example.com' }, undefined, 'mydb');
66
+
51
67
  // Update document
52
68
  await helper.update(
53
69
  'users',
@@ -55,6 +71,15 @@ await helper.update(
55
71
  { $set: { age: 31 } }
56
72
  );
57
73
 
74
+ // Update in specific database
75
+ await helper.update(
76
+ 'users',
77
+ { email: 'jane@example.com' },
78
+ { $set: { age: 29 } },
79
+ undefined,
80
+ 'mydb'
81
+ );
82
+
58
83
  // Delete document
59
84
  await helper.delete('users', { email: 'john@example.com' });
60
85
 
@@ -62,6 +87,8 @@ await helper.delete('users', { email: 'john@example.com' });
62
87
  await helper.disconnect();
63
88
  ```
64
89
 
90
+ **Note:** The connection string database name (if present) is automatically stripped. All operations default to the `'admin'` database unless you specify a different database name as the last parameter.
91
+
65
92
  ## API Reference
66
93
 
67
94
  ### Constructor
@@ -71,7 +98,8 @@ new SimpleMongoHelper(connectionString: string, retryOptions?: RetryOptions)
71
98
  ```
72
99
 
73
100
  **Parameters:**
74
- - `connectionString` - MongoDB connection string
101
+ - `connectionString` - MongoDB base connection string (database name is automatically stripped if present)
102
+ - Example: `'mongodb://localhost:27017/'` or `'mongodb://localhost:27017/admin'` (both work the same)
75
103
  - `retryOptions` (optional) - Retry configuration
76
104
  - `maxRetries?: number` - Maximum retry attempts (default: 3)
77
105
  - `retryDelay?: number` - Initial retry delay in ms (default: 1000)
@@ -79,12 +107,15 @@ new SimpleMongoHelper(connectionString: string, retryOptions?: RetryOptions)
79
107
 
80
108
  **Example:**
81
109
  ```typescript
110
+ // Database name in connection string is ignored/stripped
82
111
  const helper = new SimpleMongoHelper(
83
- 'mongodb://localhost:27017/my-db',
112
+ 'mongodb://localhost:27017/', // or 'mongodb://localhost:27017/admin'
84
113
  { maxRetries: 5, retryDelay: 2000 }
85
114
  );
86
115
  ```
87
116
 
117
+ **Important:** The database name in the connection string is automatically stripped. All operations default to the `'admin'` database unless you specify a different database name per operation.
118
+
88
119
  ### Connection Methods
89
120
 
90
121
  #### `testConnection(): Promise<{ success: boolean; error?: { type: string; message: string; details?: string } }>`
@@ -129,15 +160,21 @@ await helper.initialize();
129
160
 
130
161
  #### `disconnect(): Promise<void>`
131
162
 
132
- Closes the MongoDB connection and cleans up resources.
163
+ Closes the MongoDB connection and cleans up resources. **Note:** Connections are automatically closed when your application exits (handles SIGINT, SIGTERM, and beforeExit events), so manual disconnection is optional but recommended for explicit cleanup.
133
164
 
134
165
  ```typescript
135
166
  await helper.disconnect();
136
167
  ```
137
168
 
169
+ **Automatic Cleanup:**
170
+ - Connections are automatically closed when the Node.js process receives `SIGINT` (Ctrl+C) or `SIGTERM` signals
171
+ - All `SimpleMongoHelper` instances are cleaned up in parallel with a 5-second timeout
172
+ - Multiple instances are handled gracefully through a global registry
173
+ - Manual `disconnect()` is still recommended for explicit cleanup in your code
174
+
138
175
  ### Query Methods
139
176
 
140
- #### `loadCollection<T>(collectionName: string, query?: Filter<T>, options?: PaginationOptions): Promise<WithId<T>[] | PaginatedResult<T>>`
177
+ #### `loadCollection<T>(collectionName: string, query?: Filter<T>, options?: PaginationOptions, database?: string): Promise<WithId<T>[] | PaginatedResult<T>>`
141
178
 
142
179
  Loads documents from a collection with optional query filter and pagination.
143
180
 
@@ -148,6 +185,7 @@ Loads documents from a collection with optional query filter and pagination.
148
185
  - `page?: number` - Page number (1-indexed)
149
186
  - `limit?: number` - Documents per page
150
187
  - `sort?: Sort` - Sort specification
188
+ - `database` (optional) - Database name (defaults to `'admin'`)
151
189
 
152
190
  **Returns:**
153
191
  - Without pagination: `WithId<T>[]`
@@ -155,9 +193,12 @@ Loads documents from a collection with optional query filter and pagination.
155
193
 
156
194
  **Examples:**
157
195
  ```typescript
158
- // Load all documents
196
+ // Load all documents from 'admin' database (default)
159
197
  const allUsers = await helper.loadCollection('users');
160
198
 
199
+ // Load from specific database
200
+ const mydbUsers = await helper.loadCollection('users', {}, undefined, 'mydb');
201
+
161
202
  // Load with query
162
203
  const activeUsers = await helper.loadCollection('users', { active: true });
163
204
 
@@ -173,9 +214,16 @@ const result = await helper.loadCollection('users', {}, {
173
214
  // result.totalPages - total pages
174
215
  // result.hasNext - has next page
175
216
  // result.hasPrev - has previous page
217
+
218
+ // Load with pagination from specific database
219
+ const mydbResult = await helper.loadCollection('users', {}, {
220
+ page: 1,
221
+ limit: 10,
222
+ sort: { createdAt: -1 }
223
+ }, 'mydb');
176
224
  ```
177
225
 
178
- #### `findOne<T>(collectionName: string, query: Filter<T>, options?: { sort?: Sort; projection?: Document }): Promise<WithId<T> | null>`
226
+ #### `findOne<T>(collectionName: string, query: Filter<T>, options?: { sort?: Sort; projection?: Document }, database?: string): Promise<WithId<T> | null>`
179
227
 
180
228
  Finds a single document in a collection.
181
229
 
@@ -185,6 +233,7 @@ Finds a single document in a collection.
185
233
  - `options` (optional) - Find options
186
234
  - `sort?: Sort` - Sort specification
187
235
  - `projection?: Document` - Field projection
236
+ - `database` (optional) - Database name (defaults to `'admin'`)
188
237
 
189
238
  **Example:**
190
239
  ```typescript
@@ -194,7 +243,7 @@ const latestUser = await helper.findOne('users', {}, { sort: { createdAt: -1 } }
194
243
 
195
244
  ### Insert Methods
196
245
 
197
- #### `insert<T>(collectionName: string, data: T | T[], options?: { session?: ClientSession }): Promise<any>`
246
+ #### `insert<T>(collectionName: string, data: T | T[], options?: { session?: ClientSession }, database?: string): Promise<any>`
198
247
 
199
248
  Inserts one or more documents into a collection.
200
249
 
@@ -227,7 +276,7 @@ await session.withTransaction(async () => {
227
276
 
228
277
  ### Update Methods
229
278
 
230
- #### `update<T>(collectionName: string, filter: Filter<T>, updateData: UpdateFilter<T>, options?: { upsert?: boolean; multi?: boolean; session?: ClientSession }): Promise<any>`
279
+ #### `update<T>(collectionName: string, filter: Filter<T>, updateData: UpdateFilter<T>, options?: { upsert?: boolean; multi?: boolean; session?: ClientSession }, database?: string): Promise<any>`
231
280
 
232
281
  Updates documents in a collection.
233
282
 
@@ -268,7 +317,7 @@ await helper.update(
268
317
 
269
318
  ### Delete Methods
270
319
 
271
- #### `delete<T>(collectionName: string, filter: Filter<T>, options?: { multi?: boolean }): Promise<any>`
320
+ #### `delete<T>(collectionName: string, filter: Filter<T>, options?: { multi?: boolean }, database?: string): Promise<any>`
272
321
 
273
322
  Deletes documents from a collection.
274
323
 
@@ -307,6 +356,7 @@ Merges two collections into a new target collection using various strategies (in
307
356
  - `onUnmatched1` - (Deprecated: use `joinType` instead) What to do with unmatched records from collection 1: 'include' | 'skip' (default: 'include')
308
357
  - `onUnmatched2` - (Deprecated: use `joinType` instead) What to do with unmatched records from collection 2: 'include' | 'skip' (default: 'include')
309
358
  - `session` - Optional transaction session
359
+ - `database` - Optional database name (defaults to `'admin'`)
310
360
 
311
361
  **Returns:**
312
362
  ```typescript
@@ -484,10 +534,15 @@ const result6 = await helper.mergeCollections({
484
534
 
485
535
  ### Count Methods
486
536
 
487
- #### `countDocuments<T>(collectionName: string, query?: Filter<T>): Promise<number>`
537
+ #### `countDocuments<T>(collectionName: string, query?: Filter<T>, database?: string): Promise<number>`
488
538
 
489
539
  Counts documents matching a query (accurate count).
490
540
 
541
+ **Parameters:**
542
+ - `collectionName` - Name of the collection
543
+ - `query` (optional) - MongoDB query filter
544
+ - `database` (optional) - Database name (defaults to `'admin'`)
545
+
491
546
  **Example:**
492
547
  ```typescript
493
548
  const userCount = await helper.countDocuments('users');
@@ -505,10 +560,15 @@ const estimatedCount = await helper.estimatedDocumentCount('users');
505
560
 
506
561
  ### Aggregation Methods
507
562
 
508
- #### `aggregate<T>(collectionName: string, pipeline: Document[]): Promise<T[]>`
563
+ #### `aggregate<T>(collectionName: string, pipeline: Document[], database?: string): Promise<T[]>`
509
564
 
510
565
  Runs an aggregation pipeline on a collection.
511
566
 
567
+ **Parameters:**
568
+ - `collectionName` - Name of the collection
569
+ - `pipeline` - Array of aggregation pipeline stages
570
+ - `database` (optional) - Database name (defaults to `'admin'`)
571
+
512
572
  **Example:**
513
573
  ```typescript
514
574
  const result = await helper.aggregate('orders', [
@@ -524,10 +584,16 @@ const result = await helper.aggregate('orders', [
524
584
 
525
585
  ### Index Methods
526
586
 
527
- #### `createIndex(collectionName: string, indexSpec: IndexSpecification, options?: CreateIndexesOptions): Promise<string>`
587
+ #### `createIndex(collectionName: string, indexSpec: IndexSpecification, options?: CreateIndexesOptions, database?: string): Promise<string>`
528
588
 
529
589
  Creates an index on a collection.
530
590
 
591
+ **Parameters:**
592
+ - `collectionName` - Name of the collection
593
+ - `indexSpec` - Index specification
594
+ - `options` (optional) - Index creation options
595
+ - `database` (optional) - Database name (defaults to `'admin'`)
596
+
531
597
  **Example:**
532
598
  ```typescript
533
599
  // Simple index
@@ -540,19 +606,28 @@ await helper.createIndex('users', { email: 1 }, { unique: true });
540
606
  await helper.createIndex('users', { email: 1, createdAt: -1 });
541
607
  ```
542
608
 
543
- #### `dropIndex(collectionName: string, indexName: string): Promise<any>`
609
+ #### `dropIndex(collectionName: string, indexName: string, database?: string): Promise<any>`
544
610
 
545
611
  Drops an index from a collection.
546
612
 
613
+ **Parameters:**
614
+ - `collectionName` - Name of the collection
615
+ - `indexName` - Name of the index to drop
616
+ - `database` (optional) - Database name (defaults to `'admin'`)
617
+
547
618
  **Example:**
548
619
  ```typescript
549
620
  await helper.dropIndex('users', 'email_1');
550
621
  ```
551
622
 
552
- #### `listIndexes(collectionName: string): Promise<Document[]>`
623
+ #### `listIndexes(collectionName: string, database?: string): Promise<Document[]>`
553
624
 
554
625
  Lists all indexes on a collection.
555
626
 
627
+ **Parameters:**
628
+ - `collectionName` - Name of the collection
629
+ - `database` (optional) - Database name (defaults to `'admin'`)
630
+
556
631
  **Example:**
557
632
  ```typescript
558
633
  const indexes = await helper.listIndexes('users');
@@ -614,6 +689,11 @@ interface HelperConfig {
614
689
  uniqueIndexKeys?: string[]; // Unique index keys (default: ["provider","key"])
615
690
  provider?: string; // Default provider namespace for this helper instance
616
691
  };
692
+ databases?: Array<{
693
+ ref: string; // Reference identifier
694
+ type: string; // Type identifier
695
+ database: string; // Database name to use
696
+ }>;
617
697
  }
618
698
  ```
619
699
 
@@ -662,15 +742,101 @@ Sets or updates the configuration for ref-based operations.
662
742
  helper.useConfig(config);
663
743
  ```
664
744
 
745
+ ### Database Selection via Ref/Type Map
746
+
747
+ The helper supports config-driven database selection using `ref` and `type` parameters. This allows you to map logical identifiers to database names without hardcoding them in your application code.
748
+
749
+ **Configuration:**
750
+
751
+ ```typescript
752
+ const config = {
753
+ // ... inputs, outputs, etc.
754
+ databases: [
755
+ { ref: "app1", type: "production", database: "app1_prod" },
756
+ { ref: "app1", type: "staging", database: "app1_staging" },
757
+ { ref: "app2", type: "production", database: "app2_prod" },
758
+ { ref: "app2", type: "staging", database: "app2_staging" },
759
+ ]
760
+ };
761
+ ```
762
+
763
+ **Usage in CRUD Operations:**
764
+
765
+ All CRUD operations now support optional `ref` and `type` parameters for automatic database resolution:
766
+
767
+ ```typescript
768
+ // Priority 1: Direct database parameter (highest priority)
769
+ await helper.insert('users', { name: 'John' }, {}, 'mydb');
770
+
771
+ // Priority 2: Using ref + type (exact match)
772
+ await helper.insert('users', { name: 'John' }, {}, undefined, 'app1', 'production');
773
+ // Resolves to 'app1_prod' database
774
+
775
+ // Priority 3: Using ref alone (must have exactly one match)
776
+ await helper.insert('users', { name: 'John' }, {}, undefined, 'app1');
777
+ // Throws error if multiple matches found
778
+
779
+ // Priority 4: Using type alone (must have exactly one match)
780
+ await helper.insert('users', { name: 'John' }, {}, undefined, undefined, 'production');
781
+ // Throws error if multiple matches found
782
+ ```
783
+
784
+ **Database Resolution Priority:**
785
+
786
+ 1. **Direct `database` parameter** - If provided, it's used immediately (highest priority)
787
+ 2. **`ref` + `type`** - If both provided, finds exact match in databases map
788
+ 3. **`ref` alone** - If only ref provided, finds entries matching ref (must be exactly one)
789
+ 4. **`type` alone** - If only type provided, finds entries matching type (must be exactly one)
790
+ 5. **Default** - If none provided, defaults to `'admin'` database
791
+
792
+ **Error Handling:**
793
+
794
+ - If no match found: throws error with descriptive message
795
+ - If multiple matches found: throws error suggesting to use additional parameter to narrow down
796
+
797
+ **Example:**
798
+
799
+ ```typescript
800
+ const config = {
801
+ databases: [
802
+ { ref: "tenant1", type: "prod", database: "tenant1_prod" },
803
+ { ref: "tenant1", type: "dev", database: "tenant1_dev" },
804
+ { ref: "tenant2", type: "prod", database: "tenant2_prod" },
805
+ ]
806
+ };
807
+
808
+ const helper = new SimpleMongoHelper('mongodb://localhost:27017/', undefined, config);
809
+ await helper.initialize();
810
+
811
+ // Use ref + type for exact match
812
+ await helper.insert('users', { name: 'John' }, {}, undefined, 'tenant1', 'prod');
813
+ // Uses 'tenant1_prod' database
814
+
815
+ // Use ref alone (only works if exactly one match)
816
+ // This would throw error because tenant1 has 2 matches (prod and dev)
817
+ // await helper.insert('users', { name: 'John' }, {}, undefined, 'tenant1');
818
+
819
+ // Use type alone (only works if exactly one match)
820
+ // This would throw error because 'prod' has 2 matches (tenant1 and tenant2)
821
+ // await helper.insert('users', { name: 'John' }, {}, undefined, undefined, 'prod');
822
+ ```
823
+
665
824
  ### Ref-based Operations
666
825
 
667
- #### `loadByRef<T>(ref: string, options?: PaginationOptions & { session?: ClientSession }): Promise<WithId<T>[] | PaginatedResult<T>>`
826
+ #### `loadByRef<T>(ref: string, options?: PaginationOptions & { session?: ClientSession; database?: string; ref?: string; type?: string }): Promise<WithId<T>[] | PaginatedResult<T>>`
668
827
 
669
828
  Loads data from a collection using a ref name from the configuration.
670
829
 
671
830
  **Parameters:**
672
831
  - `ref` - Application-level reference name (must exist in config.inputs)
673
832
  - `options` (optional) - Pagination and session options
833
+ - `page?: number` - Page number (1-indexed)
834
+ - `limit?: number` - Documents per page
835
+ - `sort?: Sort` - Sort specification
836
+ - `session?: ClientSession` - Transaction session
837
+ - `database?: string` - Database name (defaults to `'admin'`)
838
+ - `ref?: string` - Optional ref for database resolution
839
+ - `type?: string` - Optional type for database resolution
674
840
 
675
841
  **Example:**
676
842
 
@@ -687,7 +853,7 @@ const result = await helper.loadByRef('topology', {
687
853
  });
688
854
  ```
689
855
 
690
- #### `writeByRef(ref: string, documents: any[], options?: { session?: ClientSession; ensureIndex?: boolean }): Promise<WriteByRefResult>`
856
+ #### `writeByRef(ref: string, documents: any[], options?: { session?: ClientSession; ensureIndex?: boolean; database?: string; ref?: string; type?: string }): Promise<WriteByRefResult>`
691
857
 
692
858
  Writes documents to a collection using a ref name from the configuration. Supports signature-based deduplication and append/replace modes.
693
859
 
@@ -697,6 +863,9 @@ Writes documents to a collection using a ref name from the configuration. Suppor
697
863
  - `options` (optional) - Write options
698
864
  - `session?: ClientSession` - Transaction session
699
865
  - `ensureIndex?: boolean` - Whether to ensure signature index exists (default: true)
866
+ - `database?: string` - Database name (defaults to `'admin'`)
867
+ - `ref?: string` - Optional ref for database resolution
868
+ - `type?: string` - Optional type for database resolution
700
869
 
701
870
  **Returns:**
702
871
 
@@ -725,6 +894,15 @@ await helper.writeByRef('prioritizedPaths', prioritizedDocs);
725
894
 
726
895
  Writes documents to a collection and optionally marks a stage as complete atomically. See the [Progress Tracking](#progress-tracking) section for details and examples.
727
896
 
897
+ **Parameters:**
898
+ - `ref` - Application-level reference name (must exist in config.outputs)
899
+ - `documents` - Array of documents to write
900
+ - `options` (optional) - Write and completion options
901
+ - `session?: ClientSession` - Transaction session
902
+ - `ensureIndex?: boolean` - Whether to ensure signature index exists (default: true)
903
+ - `database?: string` - Database name (defaults to `'admin'`)
904
+ - `complete?: object` - Stage completion information (optional)
905
+
728
906
  **Example:**
729
907
 
730
908
  ```typescript
@@ -1300,10 +1478,11 @@ try {
1300
1478
  await helper.createIndex('users', { email: 1 }, { unique: true });
1301
1479
  ```
1302
1480
 
1303
- 5. **Always disconnect when done:**
1481
+ 5. **Disconnect when done (optional but recommended):**
1304
1482
  ```typescript
1305
1483
  await helper.disconnect();
1306
1484
  ```
1485
+ **Note:** Connections automatically close on app exit (SIGINT/SIGTERM), but explicit disconnection is recommended for better control and immediate cleanup.
1307
1486
 
1308
1487
  ## License
1309
1488
 
@@ -1315,6 +1494,28 @@ Contributions are welcome! Please feel free to submit a Pull Request.
1315
1494
 
1316
1495
  ## Changelog
1317
1496
 
1497
+ ### 3.8.0
1498
+ - **Database selection via ref/type map**: Added config-driven database selection using `ref` and `type` parameters
1499
+ - Added `databases` array to `HelperConfig` for mapping ref/type combinations to database names
1500
+ - All CRUD operations now support optional `ref` and `type` parameters for automatic database resolution
1501
+ - Database resolution priority: direct `database` parameter > `ref` + `type` > `ref` alone > `type` alone
1502
+ - Throws descriptive errors when no match or multiple matches found in database map
1503
+ - Updated Progress API to support database resolution via ref/type
1504
+ - Updated `loadByRef`, `writeByRef`, `writeStage`, and `mergeCollections` to support database map resolution
1505
+
1506
+ ### 3.7.0
1507
+ - **Separated database from connection string**: Database name is now specified per operation, not in connection string
1508
+ - **Multi-database support**: All operations accept optional `database` parameter (defaults to `'admin'`)
1509
+ - Connection string database name is automatically stripped if present (e.g., `mongodb://localhost:27017/admin` becomes `mongodb://localhost:27017/`)
1510
+ - Updated all methods (`insert`, `update`, `delete`, `loadCollection`, `findOne`, `countDocuments`, `aggregate`, `createIndex`, `dropIndex`, `listIndexes`, `writeByRef`, `loadByRef`, `mergeCollections`, `writeStage`, and progress API) to support per-operation database selection
1511
+ - **Breaking change**: No backward compatibility - all code must be updated to use new database parameter
1512
+
1513
+ ### 3.6.0
1514
+ - **Automatic connection cleanup**: Connections now automatically close on app exit (SIGINT, SIGTERM, beforeExit)
1515
+ - **Multi-instance support**: Global registry handles multiple `SimpleMongoHelper` instances gracefully
1516
+ - **Timeout protection**: 5-second timeout prevents hanging during automatic cleanup
1517
+ - Connections are properly managed and cleaned up even if users forget to call `disconnect()`
1518
+
1318
1519
  ### 3.5.0
1319
1520
  - Enhanced `mergeCollections()` with SQL-style join types (`inner`, `left`, `right`, `outer`)
1320
1521
  - **Multiple match handling**: Now creates multiple rows when keys have duplicates (SQL-style behavior)