@twin.org/entity-storage-connector-mongodb 0.0.3-next.8 → 0.9.0-next.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"IMongoDbEntityStorageConnectorConstructorOptions.js","sourceRoot":"","sources":["../../../src/models/IMongoDbEntityStorageConnectorConstructorOptions.ts"],"names":[],"mappings":"","sourcesContent":["// Copyright 2024 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport type { IMongoDbEntityStorageConnectorConfig } from \"./IMongoDbEntityStorageConnectorConfig.js\";\n\n/**\n * The options for the MongoDb entity storage connector constructor.\n */\nexport interface IMongoDbEntityStorageConnectorConstructorOptions {\n\t/**\n\t * The schema for the entity.\n\t */\n\tentitySchema: string;\n\n\t/**\n\t * The keys to use from the context ids to create partitions.\n\t */\n\tpartitionContextIds?: string[];\n\n\t/**\n\t * The type of logging component to use.\n\t * @default logging\n\t */\n\tloggingComponentType?: string;\n\n\t/**\n\t * The configuration for the connector.\n\t */\n\tconfig: IMongoDbEntityStorageConnectorConfig;\n}\n"]}
1
+ {"version":3,"file":"IMongoDbEntityStorageConnectorConstructorOptions.js","sourceRoot":"","sources":["../../../src/models/IMongoDbEntityStorageConnectorConstructorOptions.ts"],"names":[],"mappings":"","sourcesContent":["// Copyright 2024 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport type { IMongoDbEntityStorageConnectorConfig } from \"./IMongoDbEntityStorageConnectorConfig.js\";\n\n/**\n * The options for the MongoDb entity storage connector constructor.\n */\nexport interface IMongoDbEntityStorageConnectorConstructorOptions {\n\t/**\n\t * The schema for the entity.\n\t */\n\tentitySchema: string;\n\n\t/**\n\t * The keys to use from the context ids to create partitions.\n\t */\n\tpartitionContextIds?: string[];\n\n\t/**\n\t * The type of logging component to use.\n\t */\n\tloggingComponentType?: string;\n\n\t/**\n\t * The configuration for the connector.\n\t */\n\tconfig: IMongoDbEntityStorageConnectorConfig;\n}\n"]}
@@ -1,8 +1,9 @@
1
1
  // Copyright 2024 IOTA Stiftung.
2
2
  // SPDX-License-Identifier: Apache-2.0.
3
3
  import { ContextIdHelper, ContextIdStore } from "@twin.org/context";
4
- import { BaseError, ComponentFactory, GeneralError, Guards, Is, ObjectHelper } from "@twin.org/core";
4
+ import { BaseError, ComponentFactory, GeneralError, Guards, HealthStatus, Is, ObjectHelper, Validation } from "@twin.org/core";
5
5
  import { ComparisonOperator, EntitySchemaFactory, EntitySchemaHelper, EntitySchemaPropertyType, LogicalOperator } from "@twin.org/entity";
6
+ import { EntityStorageHelper } from "@twin.org/entity-storage-models";
6
7
  import { MongoClient } from "mongodb";
7
8
  /**
8
9
  * Class for performing entity storage operations using MongoDb.
@@ -18,10 +19,10 @@ export class MongoDbEntityStorageConnector {
18
19
  */
19
20
  static _DEFAULT_LIMIT = 40;
20
21
  /**
21
- * Partition id field name.
22
+ * The name for the schema.
22
23
  * @internal
23
24
  */
24
- static _PARTITION_KEY = "partitionId";
25
+ _entitySchemaName;
25
26
  /**
26
27
  * The schema for the entity.
27
28
  * @internal
@@ -53,6 +54,7 @@ export class MongoDbEntityStorageConnector {
53
54
  Guards.stringValue(MongoDbEntityStorageConnector.CLASS_NAME, "options.config.host", options.config.host);
54
55
  Guards.stringValue(MongoDbEntityStorageConnector.CLASS_NAME, "options.config.database", options.config.database);
55
56
  Guards.stringValue(MongoDbEntityStorageConnector.CLASS_NAME, "options.config.collection", options.config.collection);
57
+ this._entitySchemaName = options.entitySchema;
56
58
  this._entitySchema = EntitySchemaFactory.get(options.entitySchema);
57
59
  this._partitionContextIds = options.partitionContextIds;
58
60
  this._config = options.config;
@@ -113,6 +115,14 @@ export class MongoDbEntityStorageConnector {
113
115
  }
114
116
  return true;
115
117
  }
118
+ /**
119
+ * The component needs to be stopped when the node is closed.
120
+ * @param nodeLoggingComponentType The node logging component type.
121
+ * @returns Nothing.
122
+ */
123
+ async stop(nodeLoggingComponentType) {
124
+ await this._client.close();
125
+ }
116
126
  /**
117
127
  * Returns the class name of the component.
118
128
  * @returns The class name of the component.
@@ -120,6 +130,37 @@ export class MongoDbEntityStorageConnector {
120
130
  className() {
121
131
  return MongoDbEntityStorageConnector.CLASS_NAME;
122
132
  }
133
+ /**
134
+ * Returns the health status of the component.
135
+ * @returns The health status of the component.
136
+ */
137
+ async health() {
138
+ try {
139
+ await this._client
140
+ .db(this._config.database)
141
+ .collection(this._config.collection)
142
+ .estimatedDocumentCount();
143
+ return [
144
+ {
145
+ source: MongoDbEntityStorageConnector.CLASS_NAME,
146
+ status: HealthStatus.Ok,
147
+ description: "healthDescription",
148
+ data: { database: this._config.database, collection: this._config.collection }
149
+ }
150
+ ];
151
+ }
152
+ catch {
153
+ return [
154
+ {
155
+ source: MongoDbEntityStorageConnector.CLASS_NAME,
156
+ status: HealthStatus.Error,
157
+ description: "healthDescription",
158
+ message: "connectionFailed",
159
+ data: { database: this._config.database, collection: this._config.collection }
160
+ }
161
+ ];
162
+ }
163
+ }
123
164
  /**
124
165
  * Get the schema for the entities.
125
166
  * @returns The schema for the entities.
@@ -136,16 +177,11 @@ export class MongoDbEntityStorageConnector {
136
177
  */
137
178
  async get(id, secondaryIndex, conditions) {
138
179
  Guards.stringValue(MongoDbEntityStorageConnector.CLASS_NAME, "id", id);
139
- const contextIds = await ContextIdStore.getContextIds();
140
- const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
141
180
  try {
142
181
  const primaryKey = EntitySchemaHelper.getPrimaryKey(this.getSchema());
143
182
  const query = Is.empty(secondaryIndex)
144
183
  ? { [primaryKey.property]: id }
145
184
  : { [secondaryIndex]: id };
146
- if (Is.stringValue(partitionKey)) {
147
- query[MongoDbEntityStorageConnector._PARTITION_KEY] = partitionKey;
148
- }
149
185
  if (conditions) {
150
186
  for (const condition of conditions) {
151
187
  query[condition.property] = condition.value;
@@ -154,8 +190,9 @@ export class MongoDbEntityStorageConnector {
154
190
  const collection = await this.getCollection();
155
191
  const result = await collection.findOne(query);
156
192
  ObjectHelper.propertyDelete(result, "_id");
157
- ObjectHelper.propertyDelete(result, MongoDbEntityStorageConnector._PARTITION_KEY);
158
- return result;
193
+ return Is.objectValue(result)
194
+ ? EntityStorageHelper.unPrepareEntity(result, [])
195
+ : undefined;
159
196
  }
160
197
  catch (err) {
161
198
  throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "getFailed", {
@@ -171,25 +208,20 @@ export class MongoDbEntityStorageConnector {
171
208
  */
172
209
  async set(entity, conditions) {
173
210
  Guards.object(MongoDbEntityStorageConnector.CLASS_NAME, "entity", entity);
174
- const contextIds = await ContextIdStore.getContextIds();
175
- const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
176
- EntitySchemaHelper.validateEntity(entity, this.getSchema());
211
+ const prepared = EntityStorageHelper.prepareEntity(entity, this._entitySchema, undefined, {
212
+ nullBehavior: "omit"
213
+ });
177
214
  const primaryKey = EntitySchemaHelper.getPrimaryKey(this.getSchema());
178
- const id = entity[primaryKey.property];
215
+ const id = prepared[primaryKey.property];
179
216
  try {
180
217
  const filter = { [primaryKey.property]: id };
181
- const finalEntity = ObjectHelper.clone(entity);
182
- if (Is.stringValue(partitionKey)) {
183
- filter[MongoDbEntityStorageConnector._PARTITION_KEY] = partitionKey;
184
- ObjectHelper.propertySet(finalEntity, MongoDbEntityStorageConnector._PARTITION_KEY, partitionKey);
185
- }
186
218
  if (Is.arrayValue(conditions)) {
187
219
  for (const condition of conditions) {
188
220
  filter[condition.property] = condition.value;
189
221
  }
190
222
  }
191
223
  const collection = await this.getCollection();
192
- await collection.findOneAndUpdate(filter, { $set: ObjectHelper.removeEmptyProperties(finalEntity) }, { upsert: true });
224
+ await collection.findOneAndUpdate(filter, { $set: prepared }, { upsert: true });
193
225
  }
194
226
  catch (err) {
195
227
  throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "setFailed", {
@@ -197,6 +229,51 @@ export class MongoDbEntityStorageConnector {
197
229
  }, err);
198
230
  }
199
231
  }
232
+ /**
233
+ * Set multiple entities in a batch.
234
+ * @param entities The entities to set.
235
+ * @returns Nothing.
236
+ */
237
+ async setBatch(entities) {
238
+ Guards.arrayValue(MongoDbEntityStorageConnector.CLASS_NAME, "entities", entities);
239
+ const primaryKey = EntitySchemaHelper.getPrimaryKey(this.getSchema());
240
+ const preparedEntities = entities.map(entity => EntityStorageHelper.prepareEntity(entity, this._entitySchema, undefined, {
241
+ nullBehavior: "omit"
242
+ }));
243
+ try {
244
+ const collection = await this.getCollection();
245
+ await collection.bulkWrite(preparedEntities.map(prepared => {
246
+ const filter = {
247
+ [primaryKey.property]: prepared[primaryKey.property]
248
+ };
249
+ return {
250
+ updateOne: {
251
+ filter,
252
+ update: {
253
+ $set: prepared
254
+ },
255
+ upsert: true
256
+ }
257
+ };
258
+ }));
259
+ }
260
+ catch (err) {
261
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "setBatchFailed", undefined, err);
262
+ }
263
+ }
264
+ /**
265
+ * Empty the entity storage.
266
+ * @returns Nothing.
267
+ */
268
+ async empty() {
269
+ try {
270
+ const collection = await this.getCollection();
271
+ await collection.deleteMany({});
272
+ }
273
+ catch (err) {
274
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
275
+ }
276
+ }
200
277
  /**
201
278
  * Remove the entity.
202
279
  * @param id The id of the entity to remove.
@@ -205,14 +282,9 @@ export class MongoDbEntityStorageConnector {
205
282
  */
206
283
  async remove(id, conditions) {
207
284
  Guards.stringValue(MongoDbEntityStorageConnector.CLASS_NAME, "id", id);
208
- const contextIds = await ContextIdStore.getContextIds();
209
- const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
210
285
  try {
211
286
  const primaryKey = EntitySchemaHelper.getPrimaryKey(this.getSchema());
212
287
  const query = { [primaryKey.property]: id };
213
- if (Is.stringValue(partitionKey)) {
214
- query[MongoDbEntityStorageConnector._PARTITION_KEY] = partitionKey;
215
- }
216
288
  if (conditions) {
217
289
  for (const condition of conditions) {
218
290
  query[condition.property] = condition.value;
@@ -225,6 +297,74 @@ export class MongoDbEntityStorageConnector {
225
297
  throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "removeFailed", { id }, err);
226
298
  }
227
299
  }
300
+ /**
301
+ * Remove multiple entities by id.
302
+ * @param ids The ids of the entities to remove.
303
+ * @returns Nothing.
304
+ */
305
+ async removeBatch(ids) {
306
+ Guards.arrayValue(MongoDbEntityStorageConnector.CLASS_NAME, "ids", ids);
307
+ try {
308
+ const primaryKey = EntitySchemaHelper.getPrimaryKey(this.getSchema());
309
+ const filter = {
310
+ [primaryKey.property]: { $in: ids }
311
+ };
312
+ const collection = await this.getCollection();
313
+ await collection.deleteMany(filter);
314
+ }
315
+ catch (err) {
316
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "removeBatchFailed", undefined, err);
317
+ }
318
+ }
319
+ /**
320
+ * Teardown the entity storage by dropping the collection.
321
+ * @param nodeLoggingComponentType The node logging component type.
322
+ * @returns True if the teardown process was successful.
323
+ */
324
+ async teardown(nodeLoggingComponentType) {
325
+ const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
326
+ await nodeLogging?.log({
327
+ level: "info",
328
+ source: MongoDbEntityStorageConnector.CLASS_NAME,
329
+ ts: Date.now(),
330
+ message: "collectionDropping",
331
+ data: { collection: this._config.collection }
332
+ });
333
+ try {
334
+ if (Is.arrayValue(this._partitionContextIds)) {
335
+ const db = this._client.db(this._config.database);
336
+ const collections = await this.listPartitionCollections();
337
+ for (const col of collections) {
338
+ await db
339
+ .collection(col.name)
340
+ .drop()
341
+ .catch(() => { });
342
+ }
343
+ }
344
+ else {
345
+ const collection = await this.getCollection();
346
+ await collection.drop();
347
+ }
348
+ await nodeLogging?.log({
349
+ level: "info",
350
+ source: MongoDbEntityStorageConnector.CLASS_NAME,
351
+ ts: Date.now(),
352
+ message: "collectionDropped",
353
+ data: { collection: this._config.collection }
354
+ });
355
+ return true;
356
+ }
357
+ catch (err) {
358
+ await nodeLogging?.log({
359
+ level: "error",
360
+ source: MongoDbEntityStorageConnector.CLASS_NAME,
361
+ ts: Date.now(),
362
+ message: "teardownFailed",
363
+ error: BaseError.fromError(err)
364
+ });
365
+ return false;
366
+ }
367
+ }
228
368
  /**
229
369
  * Find all the entities which match the conditions.
230
370
  * @param conditions The conditions to match for the entities.
@@ -236,27 +376,15 @@ export class MongoDbEntityStorageConnector {
236
376
  * and a cursor which can be used to request more entities.
237
377
  */
238
378
  async query(conditions, sortProperties, properties, cursor, limit) {
239
- const contextIds = await ContextIdStore.getContextIds();
240
- const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
241
- const returnSize = limit ?? MongoDbEntityStorageConnector._DEFAULT_LIMIT;
242
- const finalConditions = {
243
- conditions: [],
244
- logicalOperator: LogicalOperator.And
245
- };
246
- if (Is.stringValue(partitionKey)) {
247
- finalConditions.conditions.push({
248
- property: MongoDbEntityStorageConnector._PARTITION_KEY,
249
- comparison: ComparisonOperator.Equals,
250
- value: partitionKey
251
- });
252
- }
253
- if (!Is.empty(conditions)) {
254
- finalConditions.conditions.push(conditions);
255
- }
256
- const filter = {};
257
- if (finalConditions.conditions.length > 0) {
258
- this.buildQueryParameters("", finalConditions, filter);
379
+ EntityStorageHelper.validateSortProperties(this._entitySchema, sortProperties);
380
+ EntityStorageHelper.validateProperties(this._entitySchema, properties);
381
+ if (!Is.empty(limit)) {
382
+ const validationFailures = [];
383
+ Validation.integer("limit", limit, validationFailures, undefined, { minValue: 1 });
384
+ Validation.asValidationError(MongoDbEntityStorageConnector.CLASS_NAME, "query", validationFailures);
259
385
  }
386
+ const returnSize = limit ?? MongoDbEntityStorageConnector._DEFAULT_LIMIT;
387
+ const filter = this.buildFilter(conditions);
260
388
  const sort = new Map();
261
389
  if (Array.isArray(sortProperties)) {
262
390
  for (const sortProperty of sortProperties) {
@@ -272,35 +400,121 @@ export class MongoDbEntityStorageConnector {
272
400
  const cursorValue = cursor ? Number(cursor) : 0;
273
401
  const collection = await this.getCollection();
274
402
  const entitiesResult = await collection
275
- // False positive, this is not an array find call
276
- // eslint-disable-next-line unicorn/no-array-callback-reference
277
403
  ?.find(filter, { projection })
278
404
  .sort(sort)
279
405
  .skip(cursorValue)
280
- .limit(returnSize)
406
+ .limit(returnSize + 1)
281
407
  .toArray();
282
- const entities = entitiesResult ?? [];
283
- for (const entity of entities) {
408
+ const rawResults = entitiesResult ?? [];
409
+ const hasMore = rawResults.length > returnSize;
410
+ const entities = hasMore ? rawResults.slice(0, returnSize) : rawResults;
411
+ for (let i = 0; i < entities.length; i++) {
412
+ const entity = entities[i];
284
413
  ObjectHelper.propertyDelete(entity, "_id");
285
- ObjectHelper.propertyDelete(entity, MongoDbEntityStorageConnector._PARTITION_KEY);
414
+ entities[i] = EntityStorageHelper.unPrepareEntity(entity, []);
286
415
  }
287
416
  return {
288
417
  entities,
289
- cursor: entities?.length === returnSize ? String(cursorValue + returnSize) : undefined
418
+ cursor: hasMore ? String(cursorValue + returnSize) : undefined
290
419
  };
291
420
  }
292
421
  /**
293
- * Drop the collection.
294
- * @returns Nothing.
422
+ * Count all the entities which match the conditions.
423
+ * @param conditions The optional conditions to match for the entities.
424
+ * @returns The total count of entities in the storage.
295
425
  */
296
- async collectionDrop() {
426
+ async count(conditions) {
297
427
  try {
428
+ const filter = this.buildFilter(conditions);
298
429
  const collection = await this.getCollection();
299
- await collection.drop();
430
+ return await collection.countDocuments(filter);
300
431
  }
301
- catch {
302
- // Ignore errors
432
+ catch (err) {
433
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "countFailed", undefined, err);
434
+ }
435
+ }
436
+ /**
437
+ * Get all unique partition context ids present in the collection.
438
+ * @returns An array of context id objects, one per unique partition.
439
+ */
440
+ async getPartitionContextIds() {
441
+ if (!Is.arrayValue(this._partitionContextIds)) {
442
+ return [];
443
+ }
444
+ try {
445
+ const prefix = `${this._config.collection}_`;
446
+ const collections = await this.listPartitionCollections();
447
+ return collections.map(col => ContextIdHelper.shortSplit(this._partitionContextIds ?? [], col.name.slice(prefix.length)));
448
+ }
449
+ catch (err) {
450
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "getPartitionContextIdsFailed", undefined, err);
451
+ }
452
+ }
453
+ /**
454
+ * Create the target connector for performing the migration using a temporary collection.
455
+ * @param newEntitySchema The name of the new entity schema to create the connector for.
456
+ * @returns Connector for performing the migration.
457
+ */
458
+ async createTargetConnector(newEntitySchema) {
459
+ const migrationCollectionName = `${this._config.collection}Migration${Date.now()}`;
460
+ return new MongoDbEntityStorageConnector({
461
+ entitySchema: newEntitySchema,
462
+ config: {
463
+ ...this._config,
464
+ collection: migrationCollectionName
465
+ },
466
+ partitionContextIds: this._partitionContextIds
467
+ });
468
+ }
469
+ /**
470
+ * Finalize the migration by dropping the source collection and renaming the migration collection to the original name.
471
+ * @param targetConnector The connector holding the migrated data in a temporary collection.
472
+ * @param options The options to control how the migration is finalized.
473
+ * @param loggingComponentType The logging component type to use during finalization.
474
+ * @returns The final connector using the original collection name with the new schema.
475
+ */
476
+ async finalizeMigration(targetConnector, options, loggingComponentType) {
477
+ // With collection-per-partition each partition is a separate collection, so we must
478
+ // rename every target partition collection to the corresponding source name. We do this
479
+ // without relying on context so that all partitions are handled in a single call.
480
+ const targetBase = targetConnector._config.collection;
481
+ const sourceBase = this._config.collection;
482
+ const targetDb = targetConnector._client.db(targetConnector._config.database);
483
+ const sourceDb = this._client.db(this._config.database);
484
+ // Find all collections the target connector wrote to (exact base name or with a _suffix).
485
+ const allCollections = await targetDb.listCollections().toArray();
486
+ const migrationCollections = allCollections.filter(c => c.name === targetBase || c.name.startsWith(`${targetBase}_`));
487
+ for (const col of migrationCollections) {
488
+ // Preserve whatever suffix (empty, or "_partitionKey") was appended to the base name.
489
+ const suffix = col.name.slice(targetBase.length);
490
+ const finalName = `${sourceBase}${suffix}`;
491
+ // Drop the existing source collection to free up the name.
492
+ try {
493
+ await sourceDb.collection(finalName).drop();
494
+ }
495
+ catch { } // collection may not exist yet
496
+ await targetDb.collection(col.name).rename(finalName);
303
497
  }
498
+ const finalConnector = new MongoDbEntityStorageConnector({
499
+ entitySchema: targetConnector._entitySchemaName,
500
+ config: this._config,
501
+ partitionContextIds: this._partitionContextIds
502
+ });
503
+ if (await finalConnector.bootstrap(loggingComponentType)) {
504
+ await targetConnector.stop?.();
505
+ return finalConnector;
506
+ }
507
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "finalizeMigrationFailedBootstrap", undefined);
508
+ }
509
+ /**
510
+ * Cleanup a failed or aborted migration by dropping the temporary migration collection.
511
+ * @param targetConnector The target connector to cleanup.
512
+ * @param options The options to control how the migration is cleaned up.
513
+ * @param loggingComponentType The optional component type to use for logging.
514
+ * @returns A promise that resolves when the cleanup is complete.
515
+ */
516
+ async cleanupMigration(targetConnector, options, loggingComponentType) {
517
+ await targetConnector?.teardown?.(loggingComponentType);
304
518
  }
305
519
  /**
306
520
  * Create a new DB connection configuration.
@@ -316,13 +530,63 @@ export class MongoDbEntityStorageConnector {
316
530
  return `mongodb://${host}${portPart}/${database}`;
317
531
  }
318
532
  /**
319
- * Return a Mongo DB collection.
533
+ * Return a Mongo DB collection for the current partition context.
320
534
  * @returns The MongoDb collection.
321
535
  * @internal
322
536
  */
323
537
  async getCollection() {
324
- const { database, collection } = this._config;
325
- return this._client.db(database).collection(collection);
538
+ const collectionName = await this.resolveCollectionName(this._config.collection);
539
+ return this._client.db(this._config.database).collection(collectionName);
540
+ }
541
+ /**
542
+ * Resolve the collection name for a base name, appending the partition key when applicable.
543
+ * @param base The base collection name.
544
+ * @returns The resolved collection name.
545
+ * @internal
546
+ */
547
+ async resolveCollectionName(base) {
548
+ if (!Is.arrayValue(this._partitionContextIds)) {
549
+ return base;
550
+ }
551
+ const contextIds = await ContextIdStore.getContextIds();
552
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
553
+ return Is.stringValue(partitionKey) ? `${base}_${partitionKey.replace(/[\0$]/g, "_")}` : base;
554
+ }
555
+ /**
556
+ * Escape special regex characters in a string for use in a MongoDB $regex query.
557
+ * @param value The string to escape.
558
+ * @returns The escaped string.
559
+ * @internal
560
+ */
561
+ escapeRegex(value) {
562
+ return value.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
563
+ }
564
+ /**
565
+ * List all collections that belong to this connector's partition set.
566
+ * @returns The collection info objects whose names share the connector's base prefix.
567
+ * @internal
568
+ */
569
+ async listPartitionCollections() {
570
+ const prefix = `${this._config.collection}_`;
571
+ const db = this._client.db(this._config.database);
572
+ return db.listCollections({ name: { $regex: `^${this.escapeRegex(prefix)}` } }).toArray();
573
+ }
574
+ /**
575
+ * Build a MongoDB filter from optional conditions.
576
+ * @param conditions The optional entity conditions to include.
577
+ * @returns The MongoDB filter object.
578
+ * @internal
579
+ */
580
+ buildFilter(conditions) {
581
+ const filter = {};
582
+ if (!Is.empty(conditions)) {
583
+ const finalConditions = {
584
+ conditions: [conditions],
585
+ logicalOperator: LogicalOperator.And
586
+ };
587
+ this.buildQueryParameters("", finalConditions, filter);
588
+ }
589
+ return filter;
326
590
  }
327
591
  /**
328
592
  * Create an MongoDB filter query.
@@ -372,6 +636,7 @@ export class MongoDbEntityStorageConnector {
372
636
  * @param value The value to compare.
373
637
  * @param type The type of the property from the schema.
374
638
  * @returns The MongoDB comparison expression.
639
+ * @throws GeneralError if the comparison operator is not supported.
375
640
  * @internal
376
641
  */
377
642
  mapComparisonOperator(comparison, value, type) {
@@ -394,7 +659,7 @@ export class MongoDbEntityStorageConnector {
394
659
  // For string fields, use regex for substring matching
395
660
  if (type === EntitySchemaPropertyType.String) {
396
661
  // Escape special regex characters in the value
397
- const escapedValue = String(value).replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
662
+ const escapedValue = this.escapeRegex(String(value));
398
663
  return { $regex: escapedValue };
399
664
  }
400
665
  // For array and object fields, use $elemMatch
@@ -406,11 +671,13 @@ export class MongoDbEntityStorageConnector {
406
671
  case ComparisonOperator.NotIncludes:
407
672
  // For string fields, use negated regex
408
673
  if (type === EntitySchemaPropertyType.String) {
409
- const escapedValue = String(value).replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
674
+ const escapedValue = this.escapeRegex(String(value));
410
675
  return { $not: { $regex: escapedValue } };
411
676
  }
412
- // For arrays, use $elemMatch with $ne
413
- return { $elemMatch: { $ne: value } };
677
+ // For array/object fields: $ne on an array field matches documents where
678
+ // none of the array elements equal the value (MongoDB element-wise semantics).
679
+ // $elemMatch: { $ne: value } is wrong — it matches if *any* element ≠ value.
680
+ return { $ne: value };
414
681
  default:
415
682
  throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "unsupportedComparisonOperator", { comparison });
416
683
  }