cry-db 2.4.18 → 2.4.21

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/dist/mongo.mjs CHANGED
@@ -4,7 +4,7 @@ import cloneDeep from "lodash.clonedeep";
4
4
  import { ObjectId, ReadConcern, ReadPreference, Timestamp, WriteConcern } from 'mongodb';
5
5
  import { TypedEmitter } from "tiny-typed-emitter";
6
6
  import { Db, log } from './db.mjs';
7
- import { SEQUENCES_COLLECTION } from './types.mjs';
7
+ import { FIELD_SEQUENCES_COLLECTION, SEQUENCES_COLLECTION } from './types.mjs';
8
8
  import { Base } from "./base.mjs";
9
9
  const assert = (cond, msg) => {
10
10
  if (!cond) {
@@ -70,16 +70,19 @@ export class Mongo extends Db {
70
70
  super(db, url);
71
71
  this.revisions = false;
72
72
  this.softdelete = false;
73
+ this.softarchive = false;
73
74
  this.syncSupport = false;
74
75
  this.session = undefined;
75
76
  this.emittingPublishEvents = false;
76
77
  this.emittingPublishRevEvents = false;
77
78
  this.auditing = false;
78
79
  this.auditCollectionName = "dblog";
80
+ this.auditedCollectionsLower = [];
79
81
  this.auditedCollections = this.auditCollections(process.env.AUDIT_COLLECTIONS || []);
80
82
  this.emitter = new TypedEmitter();
81
83
  this.user = undefined;
82
84
  this.audit = undefined;
85
+ this._sequencesIndexEnsured = false;
83
86
  fjLog.debug('new Mongo:', this.url, this.db);
84
87
  }
85
88
  on(evt, listener) {
@@ -112,6 +115,17 @@ export class Mongo extends Db {
112
115
  usesSoftDelete() {
113
116
  return this.softdelete;
114
117
  }
118
+ /**
119
+ * Enable or disable archive filtering.
120
+ * When enabled, query operations exclude records with `_archived` set,
121
+ * unless `returnArchived: true` is passed in opts.
122
+ */
123
+ useArchive(enabled) {
124
+ return this.softarchive = !!enabled;
125
+ }
126
+ usesArchive() {
127
+ return this.softarchive;
128
+ }
115
129
  useAuditing(enabled) {
116
130
  return this.auditing = enabled;
117
131
  }
@@ -137,9 +151,11 @@ export class Mongo extends Db {
137
151
  if (this.auditedCollections.includes(coll))
138
152
  return;
139
153
  this.auditedCollections.push(coll);
154
+ this.auditedCollectionsLower.push(coll);
140
155
  }
141
156
  else {
142
157
  this.auditedCollections = this.auditedCollections.filter((c) => c != coll);
158
+ this.auditedCollectionsLower = this.auditedCollectionsLower.filter((c) => c != coll);
143
159
  }
144
160
  }
145
161
  auditCollections(arr) {
@@ -149,6 +165,7 @@ export class Mongo extends Db {
149
165
  this.auditedCollections = arr.toString().split(',').map(a => a.trim().toLowerCase()).filter(a => !!a);
150
166
  if (arr instanceof Array)
151
167
  this.auditedCollections = arr.map(a => a.trim().toLowerCase()).filter(a => !!a);
168
+ this.auditedCollectionsLower = this.auditedCollections.map(a => a.toLowerCase());
152
169
  return this.auditedCollections;
153
170
  }
154
171
  auditingCollection(coll, db = this.db) {
@@ -172,11 +189,19 @@ export class Mongo extends Db {
172
189
  emitsPublishRevEvents() {
173
190
  return this.emittingPublishRevEvents;
174
191
  }
175
- async distinct(collection, field) {
192
+ /**
193
+ * Returns distinct values for a field. Results are sorted.
194
+ * @param opts - Use `{ collation: { locale: "en", strength: 2 } }` for case-insensitive
195
+ */
196
+ async distinct(collection, field, opts = {}) {
197
+ assert(collection);
198
+ assert(field);
176
199
  fjLog.debug('distinct called', collection, field);
177
- let ret = await this.executeTransactionally(collection, async (conn) => {
178
- return await conn.distinct(field);
179
- }, false, { operation: "distinct", collection, field });
200
+ const dbName = this.db;
201
+ let client = await this.connect();
202
+ let conn = client.db(dbName).collection(collection);
203
+ let ret = await conn.distinct(field, {}, opts);
204
+ ret.sort();
180
205
  fjLog.debug('distinct returns', ret);
181
206
  return ret;
182
207
  }
@@ -186,6 +211,10 @@ export class Mongo extends Db {
186
211
  if (!query._deleted)
187
212
  query._deleted = { $exists: false };
188
213
  }
214
+ if (this.softarchive && !opts.returnArchived) {
215
+ if (!query._archived)
216
+ query._archived = { $exists: false };
217
+ }
189
218
  fjLog.debug('count called', collection, query, opts);
190
219
  let ret = await this.executeTransactionally(collection, async (conn) => {
191
220
  return await conn.countDocuments(query, opts);
@@ -200,6 +229,10 @@ export class Mongo extends Db {
200
229
  if (!query._deleted)
201
230
  query._deleted = { $exists: false };
202
231
  }
232
+ if (this.softarchive && !opts.returnArchived) {
233
+ if (!query._archived)
234
+ query._archived = { $exists: false };
235
+ }
203
236
  fjLog.debug('find called', collection, query, opts);
204
237
  let ret = await this.executeTransactionally(collection, async (conn) => {
205
238
  let r = this._applyQueryOpts(conn.find(query, this._buildFindOptions(opts)), opts);
@@ -224,6 +257,10 @@ export class Mongo extends Db {
224
257
  if (!query._deleted)
225
258
  query._deleted = { $exists: false };
226
259
  }
260
+ if (this.softarchive && !opts.returnArchived) {
261
+ if (!query._archived)
262
+ query._archived = { $exists: false };
263
+ }
227
264
  fjLog.debug('findNewer called', collection, timestamp, query, opts);
228
265
  let ret = await this.executeTransactionally(collection, async (conn) => {
229
266
  let r = this._applyQueryOpts(conn.find(query, this._buildFindOptions(opts)).sort({ _ts: 1 }), opts);
@@ -232,18 +269,22 @@ export class Mongo extends Db {
232
269
  fjLog.debug('findNewer returns', ret);
233
270
  return ret;
234
271
  }
235
- async findNewerMany(spec = []) {
272
+ async findNewerMany(spec = [], defaultOpts = {}) {
236
273
  var _a;
237
274
  fjLog.debug('findNewerMany called', spec);
238
275
  const dbName = this.db; // Capture db at operation start
239
276
  let conn = await this.connect();
240
277
  const getOneColl = async (coll) => {
241
278
  let query = this._createQueryForNewer(coll.timestamp, coll.query || {});
242
- const opts = coll.opts || {};
279
+ const opts = { ...defaultOpts, ...coll.opts };
243
280
  if (this.softdelete && !opts.returnDeleted) {
244
281
  if (!query._deleted)
245
282
  query._deleted = { $exists: false };
246
283
  }
284
+ if (this.softarchive && !opts.returnArchived) {
285
+ if (!query._archived)
286
+ query._archived = { $exists: false };
287
+ }
247
288
  if (process.env.MONGO_DEBUG_FINDNEWERMANY) {
248
289
  fjLog.debug("findNewerMany <-", coll.collection, coll.timestamp, coll.query, " -> ", JSON.stringify(query));
249
290
  }
@@ -393,6 +434,10 @@ export class Mongo extends Db {
393
434
  if (!query._deleted)
394
435
  query._deleted = { $exists: false };
395
436
  }
437
+ if (this.softarchive && !opts.returnArchived) {
438
+ if (!query._archived)
439
+ query._archived = { $exists: false };
440
+ }
396
441
  // if (!query._blocked) query._blocked = { $exists: false }; // intentionally - blocked records are returned
397
442
  fjLog.debug('findOne called', collection, query, projection);
398
443
  let ret = await this.executeTransactionally(collection, async (conn) => await conn.findOne(query, { ...(projection ? { projection } : {}), ...this._sessionOpt() }), false, { operation: "findOne", collection, query, projection });
@@ -407,30 +452,32 @@ export class Mongo extends Db {
407
452
  const dbName = this.db; // Capture db at operation start
408
453
  let query = {
409
454
  _id: Mongo._toId(id),
410
- // _deleted: { $exists: false }
411
455
  };
456
+ if (this.softdelete && !opts.returnDeleted) {
457
+ query._deleted = { $exists: false };
458
+ }
459
+ if (this.softarchive && !opts.returnArchived) {
460
+ query._archived = { $exists: false };
461
+ }
412
462
  fjLog.debug('findById called', dbName, collection, id, projection);
413
463
  fjLog.trace('findById executing with query', collection, query, projection);
414
464
  let ret = await this.executeTransactionally(collection, async (conn) => {
415
465
  let r = await conn.findOne(query, { ...(projection ? { projection } : {}), ...this._sessionOpt() });
416
466
  return r;
417
467
  }, false, { operation: "findById", collection, id, projection });
418
- if ((ret === null || ret === void 0 ? void 0 : ret._deleted) && this.softdelete && !opts.returnDeleted)
419
- ret = null;
420
468
  fjLog.debug('findById returns', ret);
421
469
  return this._processReturnedObject(ret);
422
470
  }
423
471
  async findByIdsInManyCollections(request, opts = {}) {
424
- const result = {};
425
472
  if (!request || request.length === 0)
426
- return result;
473
+ return {};
427
474
  const dbName = this.db;
428
475
  fjLog.debug('findByIdsInManyCollections called', dbName, request);
429
- for (const req of request) {
476
+ // Process all collections in parallel
477
+ const fetchOne = async (req) => {
430
478
  const { collection, ids, projection } = req;
431
479
  if (!collection || !ids || ids.length === 0) {
432
- result[collection] = [];
433
- continue;
480
+ return { collection, entities: [] };
434
481
  }
435
482
  const objectIds = ids.map(id => Mongo._toId(id));
436
483
  const entities = await this.executeTransactionally(collection, async (conn) => {
@@ -438,10 +485,18 @@ export class Mongo extends Db {
438
485
  if (this.softdelete && !opts.returnDeleted) {
439
486
  query._deleted = { $exists: false };
440
487
  }
488
+ if (this.softarchive && !opts.returnArchived) {
489
+ query._archived = { $exists: false };
490
+ }
441
491
  let r = conn.find(query, { ...(projection ? { projection } : {}), ...this._sessionOpt() });
442
492
  return await r.toArray();
443
493
  }, false, { operation: "findByIdsInManyCollections", collection, ids, projection });
444
- result[collection] = this._processReturnedObject(entities);
494
+ return { collection, entities: this._processReturnedObject(entities) };
495
+ };
496
+ const results = await Promise.all(request.map(fetchOne));
497
+ const result = {};
498
+ for (const { collection, entities } of results) {
499
+ result[collection] = entities;
445
500
  }
446
501
  fjLog.debug('findByIdsInManyCollections returns', result);
447
502
  return result;
@@ -459,6 +514,9 @@ export class Mongo extends Db {
459
514
  if (this.softdelete && !opts.returnDeleted) {
460
515
  query._deleted = { $exists: false };
461
516
  }
517
+ if (this.softarchive && !opts.returnArchived) {
518
+ query._archived = { $exists: false };
519
+ }
462
520
  let r = conn.find(query, { ...(projection ? { projection } : {}), ...this._sessionOpt() });
463
521
  return await r.toArray();
464
522
  }, false, { operation: "findByIds", collection, ids, projection });
@@ -477,7 +535,9 @@ export class Mongo extends Db {
477
535
  returnDocument: "after",
478
536
  ...this._sessionOpt()
479
537
  };
480
- update = await this._processUpdateObject(update);
538
+ if (this._hasHashedKeys(update))
539
+ await this._processHashedKeys(update);
540
+ update = this._processUpdateObject(update);
481
541
  fjLog.debug('updateOne called', collection, query, update);
482
542
  let seqKeys = this._findSequenceKeys(update.$set);
483
543
  let obj = await this.executeTransactionally(collection, async (conn, client) => {
@@ -507,7 +567,9 @@ export class Mongo extends Db {
507
567
  ...this._sessionOpt()
508
568
  };
509
569
  let _id = Mongo.toId(id || update._id) || Mongo.newid();
510
- update = await this._processUpdateObject(update);
570
+ if (this._hasHashedKeys(update))
571
+ await this._processHashedKeys(update);
572
+ update = this._processUpdateObject(update);
511
573
  fjLog.debug('save called', collection, id, update);
512
574
  let seqKeys = this._findSequenceKeys(update.$set);
513
575
  let obj = await this.executeTransactionally(collection, async (conn, client) => {
@@ -541,7 +603,9 @@ export class Mongo extends Db {
541
603
  returnDocument: "after",
542
604
  ...this._sessionOpt()
543
605
  };
544
- update = await this._processUpdateObject(update);
606
+ if (this._hasHashedKeys(update))
607
+ await this._processHashedKeys(update);
608
+ update = this._processUpdateObject(update);
545
609
  fjLog.debug('update called', collection, query, update);
546
610
  let seqKeys = this._findSequenceKeys(update.$set);
547
611
  let obj = await this.executeTransactionally(collection, async (conn, client) => {
@@ -578,7 +642,9 @@ export class Mongo extends Db {
578
642
  ...this._sessionOpt()
579
643
  };
580
644
  fjLog.debug('upsert called', collection, query, update);
581
- update = await this._processUpdateObject(update);
645
+ if (this._hasHashedKeys(update))
646
+ await this._processHashedKeys(update);
647
+ update = this._processUpdateObject(update);
582
648
  let seqKeys = this._findSequenceKeys(update.$set);
583
649
  fjLog.debug('upsert processed', collection, query, update);
584
650
  if (Object.keys(query).length === 0)
@@ -612,6 +678,7 @@ export class Mongo extends Db {
612
678
  fjLog.debug('insert called', collection, insert);
613
679
  const dbName = this.db; // Capture db at operation start
614
680
  insert = this.replaceIds(insert);
681
+ this._stripDoubleUnderscoreKeys(insert);
615
682
  if (this.revisions) {
616
683
  insert._rev = 1;
617
684
  insert._ts = Base.timestamp();
@@ -660,13 +727,22 @@ export class Mongo extends Db {
660
727
  };
661
728
  const batchData = [];
662
729
  const errors = [];
730
+ // Handle sequences in updates before processing
731
+ if (updates === null || updates === void 0 ? void 0 : updates.length) {
732
+ const updatesWithSeq = updates.filter(u => this._hasSequenceFields(u.update));
733
+ if (updatesWithSeq.length > 0) {
734
+ await this._processSequenceFieldForMany(conn, dbName, collection, updatesWithSeq.map(u => u.update));
735
+ }
736
+ }
663
737
  // Process updates (with upsert) in parallel
664
738
  if (updates === null || updates === void 0 ? void 0 : updates.length) {
665
739
  const updatePromises = updates.map(async ({ _id, update }) => {
666
740
  var _a;
667
741
  try {
668
742
  const objectId = Mongo._toId(_id);
669
- const processedUpdate = await this._processUpdateObject({ ...update });
743
+ if (this._hasHashedKeys(update))
744
+ await this._processHashedKeys(update);
745
+ const processedUpdate = this._processUpdateObject({ ...update });
670
746
  const opts = {
671
747
  upsert: true,
672
748
  returnDocument: "after",
@@ -821,48 +897,56 @@ export class Mongo extends Db {
821
897
  batch = this.replaceIds(batch);
822
898
  await Promise.all(batch.map(item => this._processHashedKeys(item === null || item === void 0 ? void 0 : item.update)));
823
899
  let ret = await this.executeTransactionally(collection, async (conn, client) => {
824
- var _a;
825
- await this._processSequenceFieldForMany(client, dbName, collection, batch.map(b => b.update));
826
- let batchData = [];
827
- let changes = [];
828
- for await (let part of batch) {
829
- let { query, update, opts } = part;
830
- let options = {
900
+ // Only process sequences if any batch item has SEQ_NEXT or SEQ_LAST
901
+ const updatesWithSeq = batch.filter(b => this._hasSequenceFields(b.update));
902
+ if (updatesWithSeq.length > 0) {
903
+ await this._processSequenceFieldForMany(client, dbName, collection, updatesWithSeq.map(b => b.update));
904
+ }
905
+ // Process all updates in parallel
906
+ const upsertPromises = batch.map(async (part) => {
907
+ var _a, _b;
908
+ const { query, update, opts } = part;
909
+ const options = {
831
910
  ...(opts || {}),
832
911
  upsert: true,
833
912
  returnDocument: "after",
834
913
  includeResultMetadata: true,
835
914
  ...this._sessionOpt()
836
915
  };
837
- update = await this._processUpdateObject(update);
838
- let result = await conn.findOneAndUpdate(query, update, options);
839
- if (!result)
840
- continue;
841
- let ret = result.value;
842
- if (ret === null || ret === void 0 ? void 0 : ret._id) {
843
- // Determine operation based on result fields
844
- let oper;
845
- if (ret._deleted !== undefined) {
846
- oper = "delete";
847
- }
848
- else if ((_a = result.lastErrorObject) === null || _a === void 0 ? void 0 : _a.updatedExisting) {
849
- oper = "update";
850
- }
851
- else {
852
- oper = "insert";
853
- }
854
- let retObj = oper === "insert" ? ret : this._removeUnchanged(ret, update, !!(opts === null || opts === void 0 ? void 0 : opts.returnFullObject));
855
- this._processReturnedObject(retObj);
856
- batchData.push({
857
- operation: oper,
858
- data: retObj
859
- });
860
- changes.push(retObj);
916
+ if (this._hasHashedKeys(update))
917
+ await this._processHashedKeys(update);
918
+ const processedUpdate = this._processUpdateObject({ ...update });
919
+ const result = await conn.findOneAndUpdate(query, processedUpdate, options);
920
+ if (!((_a = result === null || result === void 0 ? void 0 : result.value) === null || _a === void 0 ? void 0 : _a._id))
921
+ return null;
922
+ const ret = result.value;
923
+ // Determine operation based on result fields
924
+ let oper;
925
+ if (ret._deleted !== undefined) {
926
+ oper = "delete";
927
+ }
928
+ else if ((_b = result.lastErrorObject) === null || _b === void 0 ? void 0 : _b.updatedExisting) {
929
+ oper = "update";
930
+ }
931
+ else {
932
+ oper = "insert";
933
+ }
934
+ const retObj = oper === "insert" ? ret : this._removeUnchanged(ret, processedUpdate, !!(opts === null || opts === void 0 ? void 0 : opts.returnFullObject));
935
+ this._processReturnedObject(retObj);
936
+ return { operation: oper, data: retObj };
937
+ });
938
+ const results = await Promise.all(upsertPromises);
939
+ // Filter out nulls and separate batchData and changes
940
+ const batchData = [];
941
+ const changes = [];
942
+ for (const result of results) {
943
+ if (result) {
944
+ batchData.push(result);
945
+ changes.push(result.data);
861
946
  }
862
- ;
863
947
  }
864
948
  if (this.emittingPublishEvents || this.auditing) {
865
- await this.emit("publish", {
949
+ this.emit("publish", {
866
950
  channel: `db/${dbName}/${collection}`,
867
951
  payload: {
868
952
  operation: "batch",
@@ -883,7 +967,7 @@ export class Mongo extends Db {
883
967
  _ts: item.data._ts,
884
968
  }))
885
969
  };
886
- await this.emit("publishRev", {
970
+ this.emit("publishRev", {
887
971
  channel: `dbrev/${dbName}/${collection}`,
888
972
  payload,
889
973
  });
@@ -900,11 +984,16 @@ export class Mongo extends Db {
900
984
  fjLog.debug('insertMany called', collection, insert);
901
985
  const dbName = this.db; // Capture db at operation start
902
986
  insert = this.replaceIds(insert);
987
+ insert.forEach(item => this._stripDoubleUnderscoreKeys(item));
903
988
  await Promise.all(insert.map(item => this._processHashedKeys(item)));
904
989
  if (this.revisions)
905
990
  insert.forEach(ins => { ins._rev = 1; ins._ts = Base.timestamp(); });
906
991
  let ret = await this.executeTransactionally(collection, async (conn, client) => {
907
- await this._processSequenceFieldForMany(client, dbName, collection, insert);
992
+ // Only process sequences if any insert has SEQ_NEXT or SEQ_LAST
993
+ const insertsWithSeq = insert.filter(item => this._hasSequenceFields(item));
994
+ if (insertsWithSeq.length > 0) {
995
+ await this._processSequenceFieldForMany(client, dbName, collection, insert);
996
+ }
908
997
  let obj = await conn.insertMany(insert, this._sessionOpt());
909
998
  let ret = [];
910
999
  for (let ns of Object.keys(obj.insertedIds)) {
@@ -1019,6 +1108,158 @@ export class Mongo extends Db {
1019
1108
  fjLog.debug('unblockOne returns', ret);
1020
1109
  return ret;
1021
1110
  }
1111
+ /**
1112
+ * Archives a single document by setting `_archived` to the current date.
1113
+ * Increments `_rev` and updates `_ts`.
1114
+ */
1115
+ async archiveOne(collection, query) {
1116
+ let opts = {
1117
+ upsert: false,
1118
+ returnDocument: "after",
1119
+ ...this._sessionOpt()
1120
+ };
1121
+ const dbName = this.db;
1122
+ query = this.replaceIds(query);
1123
+ fjLog.debug('archiveOne called', collection, query);
1124
+ let ret = await this.executeTransactionally(collection, async (conn) => {
1125
+ query._archived = { $exists: false };
1126
+ let update = {
1127
+ $set: { _archived: new Date(), },
1128
+ $inc: { _rev: 1, },
1129
+ $currentDate: { _ts: { $type: 'timestamp' } }
1130
+ };
1131
+ let obj = await conn.findOneAndUpdate(query, update, opts);
1132
+ if (!obj || !(obj === null || obj === void 0 ? void 0 : obj._id))
1133
+ return {
1134
+ ok: false
1135
+ };
1136
+ await this._publishAndAudit('update', dbName, collection, obj);
1137
+ return obj;
1138
+ }, false, { operation: "archiveOne", collection, query });
1139
+ fjLog.debug('archiveOne returns', ret);
1140
+ return ret;
1141
+ }
1142
+ /**
1143
+ * Unarchives a single document by removing the `_archived` field.
1144
+ * Increments `_rev` and updates `_ts`.
1145
+ */
1146
+ async unarchiveOne(collection, query) {
1147
+ let opts = {
1148
+ upsert: false,
1149
+ returnDocument: "after",
1150
+ ...this._sessionOpt()
1151
+ };
1152
+ const dbName = this.db;
1153
+ query = this.replaceIds(query);
1154
+ fjLog.debug('unarchiveOne called', collection, query);
1155
+ let ret = await this.executeTransactionally(collection, async (conn) => {
1156
+ query._archived = { $exists: true };
1157
+ let update = {
1158
+ $unset: { _archived: 1 },
1159
+ $inc: { _rev: 1, },
1160
+ $currentDate: { _ts: { $type: 'timestamp' } }
1161
+ };
1162
+ let obj = await conn.findOneAndUpdate(query, update, opts);
1163
+ if (!obj || !(obj === null || obj === void 0 ? void 0 : obj._id))
1164
+ return {
1165
+ ok: false
1166
+ };
1167
+ await this._publishAndAudit('update', dbName, collection, obj);
1168
+ return obj;
1169
+ }, false, { operation: "unarchiveOne", collection, query });
1170
+ fjLog.debug('unarchiveOne returns', ret);
1171
+ return ret;
1172
+ }
1173
+ /**
1174
+ * Archives a single document found by its `_id`.
1175
+ */
1176
+ async archiveOneById(collection, id) {
1177
+ assert(collection);
1178
+ assert(id);
1179
+ return await this.archiveOne(collection, { _id: Mongo._toId(id) });
1180
+ }
1181
+ /**
1182
+ * Archives multiple documents found by their `_id`s.
1183
+ * Returns the array of archived documents.
1184
+ */
1185
+ async archiveManyByIds(collection, ids) {
1186
+ assert(collection);
1187
+ assert(ids);
1188
+ if (!ids || ids.length === 0)
1189
+ return [];
1190
+ const opts = {
1191
+ upsert: false,
1192
+ returnDocument: "after",
1193
+ ...this._sessionOpt()
1194
+ };
1195
+ const dbName = this.db;
1196
+ fjLog.debug('archiveManyByIds called', collection, ids);
1197
+ let ret = [];
1198
+ for (const id of ids) {
1199
+ let r = await this.executeTransactionally(collection, async (conn) => {
1200
+ let query = { _id: Mongo._toId(id), _archived: { $exists: false } };
1201
+ let update = {
1202
+ $set: { _archived: new Date() },
1203
+ $inc: { _rev: 1 },
1204
+ $currentDate: { _ts: { $type: 'timestamp' } }
1205
+ };
1206
+ let obj = await conn.findOneAndUpdate(query, update, opts);
1207
+ if (obj === null || obj === void 0 ? void 0 : obj._id) {
1208
+ await this._publishAndAudit('update', dbName, collection, obj);
1209
+ }
1210
+ return obj;
1211
+ }, false, { operation: "archiveManyByIds", collection, id });
1212
+ if (r === null || r === void 0 ? void 0 : r._id)
1213
+ ret.push(r);
1214
+ }
1215
+ fjLog.debug('archiveManyByIds returns', ret);
1216
+ return ret;
1217
+ }
1218
+ /**
1219
+ * Unarchives a single document found by its `_id`.
1220
+ */
1221
+ async unarchiveOneById(collection, id) {
1222
+ assert(collection);
1223
+ assert(id);
1224
+ return await this.unarchiveOne(collection, { _id: Mongo._toId(id) });
1225
+ }
1226
+ /**
1227
+ * Unarchives multiple documents found by their `_id`s.
1228
+ * Returns the array of unarchived documents.
1229
+ */
1230
+ async unarchiveManyByIds(collection, ids) {
1231
+ assert(collection);
1232
+ assert(ids);
1233
+ if (!ids || ids.length === 0)
1234
+ return [];
1235
+ const opts = {
1236
+ upsert: false,
1237
+ returnDocument: "after",
1238
+ ...this._sessionOpt()
1239
+ };
1240
+ const dbName = this.db;
1241
+ fjLog.debug('unarchiveManyByIds called', collection, ids);
1242
+ let ret = [];
1243
+ for (const id of ids) {
1244
+ let r = await this.executeTransactionally(collection, async (conn) => {
1245
+ let query = { _id: Mongo._toId(id), _archived: { $exists: true } };
1246
+ let update = {
1247
+ $unset: { _archived: 1 },
1248
+ $inc: { _rev: 1 },
1249
+ $currentDate: { _ts: { $type: 'timestamp' } }
1250
+ };
1251
+ let obj = await conn.findOneAndUpdate(query, update, opts);
1252
+ if (obj === null || obj === void 0 ? void 0 : obj._id) {
1253
+ await this._publishAndAudit('update', dbName, collection, obj);
1254
+ }
1255
+ return obj;
1256
+ }, false, { operation: "unarchiveManyByIds", collection, id });
1257
+ if (r === null || r === void 0 ? void 0 : r._id)
1258
+ ret.push(r);
1259
+ }
1260
+ fjLog.debug('unarchiveManyByIds returns', ret);
1261
+ return ret;
1262
+ }
1022
1263
  async hardDeleteOne(collection, query) {
1023
1264
  assert(collection);
1024
1265
  assert(query);
@@ -1175,35 +1416,14 @@ export class Mongo extends Db {
1175
1416
  fjLog.debug('isUnique returns', ret);
1176
1417
  return ret;
1177
1418
  }
1178
- async collectFieldValues(collection, field, inArray = false, opts) {
1179
- assert(collection);
1180
- assert(field);
1181
- fjLog.debug('collectFieldValues called', collection, field);
1182
- let pipeline = [
1183
- { $group: { _id: '$' + field } },
1184
- { $sort: { _id: 1 } }
1185
- ];
1186
- if (inArray)
1187
- pipeline = [
1188
- { $unwind: '$' + field },
1189
- ...pipeline
1190
- ];
1191
- let res = await this.executeTransactionally(collection, async (conn) => {
1192
- let agg = await conn.aggregate(pipeline, opts);
1193
- let res = await agg.toArray();
1194
- return res;
1195
- }, false, { operation: "collectFieldValues", collection, field, inArray, pipeline, opts });
1196
- let ret = res === null || res === void 0 ? void 0 : res.map((v) => v._id);
1197
- fjLog.debug('collectFieldValues returns', ret);
1198
- return ret;
1199
- }
1200
1419
  async dropCollection(collection) {
1201
1420
  assert(collection);
1202
1421
  fjLog.debug('dropCollection called', this.auditCollections);
1203
1422
  const dbName = this.db; // Capture db at operation start
1204
1423
  let client = await this.connect();
1205
1424
  let existing = await client.db(dbName).collections();
1206
- if (existing.map((c) => c.collectionName).includes(collection)) {
1425
+ const existingNames = new Set(existing.map((c) => c.collectionName));
1426
+ if (existingNames.has(collection)) {
1207
1427
  await client.db(dbName).dropCollection(collection);
1208
1428
  }
1209
1429
  fjLog.debug('dropCollection returns');
@@ -1225,8 +1445,9 @@ export class Mongo extends Db {
1225
1445
  const dbName = this.db; // Capture db at operation start
1226
1446
  let client = await this.connect();
1227
1447
  let existing = await client.db(dbName).collections();
1228
- for await (let collection of collections) {
1229
- if (existing.map((c) => c.collectionName).includes(collection)) {
1448
+ const existingNames = new Set(existing.map((c) => c.collectionName));
1449
+ for (const collection of collections) {
1450
+ if (existingNames.has(collection)) {
1230
1451
  await client.db(dbName).dropCollection(collection);
1231
1452
  }
1232
1453
  }
@@ -1239,8 +1460,9 @@ export class Mongo extends Db {
1239
1460
  const dbName = this.db; // Capture db at operation start
1240
1461
  let client = await this.connect();
1241
1462
  let existing = await this.getCollections();
1242
- for await (let collection of collections) {
1243
- if (!existing.includes(collection)) {
1463
+ const existingSet = new Set(existing);
1464
+ for (const collection of collections) {
1465
+ if (!existingSet.has(collection)) {
1244
1466
  await client.db(dbName).createCollection(collection);
1245
1467
  }
1246
1468
  }
@@ -1388,6 +1610,9 @@ export class Mongo extends Db {
1388
1610
  }
1389
1611
  // 4. Clean up state
1390
1612
  this.session = undefined;
1613
+ this.auditedCollections.length = 0;
1614
+ this.auditedCollectionsLower.length = 0;
1615
+ this._sequencesIndexEnsured = false;
1391
1616
  }
1392
1617
  async inTransaction() {
1393
1618
  if (!await this.isOnReplicaSet())
@@ -1575,6 +1800,139 @@ export class Mongo extends Db {
1575
1800
  return undefined;
1576
1801
  return parseInt((_a = maxfld === null || maxfld === void 0 ? void 0 : maxfld[0]) === null || _a === void 0 ? void 0 : _a[key]) || 0;
1577
1802
  }
1803
+ /**
1804
+ * Ensures the unique index on _sequences collection exists.
1805
+ * Called lazily on first sequence operation.
1806
+ */
1807
+ async _ensureSequencesIndex(dbConnection) {
1808
+ if (this._sequencesIndexEnsured)
1809
+ return;
1810
+ try {
1811
+ await dbConnection
1812
+ .collection(FIELD_SEQUENCES_COLLECTION)
1813
+ .createIndex({ collection: 1, field: 1 }, { unique: true });
1814
+ this._sequencesIndexEnsured = true;
1815
+ }
1816
+ catch (err) {
1817
+ // Index may already exist, which is fine
1818
+ if (err.code === 85 || err.code === 86) {
1819
+ this._sequencesIndexEnsured = true;
1820
+ }
1821
+ else {
1822
+ throw err;
1823
+ }
1824
+ }
1825
+ }
1826
+ /**
1827
+ * Atomically gets the next sequence value for a field in a collection.
1828
+ * On first use, auto-seeds from existing data in the collection.
1829
+ */
1830
+ async _getNextSequenceValue(dbConnection, collection, field) {
1831
+ // Check current max in the actual collection
1832
+ const existingMax = await this._findLastSequenceForKey(dbConnection.collection(collection), field);
1833
+ // Check if we have a sequence document
1834
+ const existingSeq = await dbConnection
1835
+ .collection(FIELD_SEQUENCES_COLLECTION)
1836
+ .findOne({ collection, field }, this._sessionOpt());
1837
+ if (!existingSeq) {
1838
+ // First time for this collection/field - ensure index exists
1839
+ await this._ensureSequencesIndex(dbConnection);
1840
+ const seedValue = (existingMax !== undefined && existingMax >= 1) ? existingMax : 0;
1841
+ // Use $max to handle concurrent seeding - only sets if greater than current value
1842
+ // This ensures the highest seed value wins in case of concurrent operations
1843
+ await dbConnection
1844
+ .collection(FIELD_SEQUENCES_COLLECTION)
1845
+ .findOneAndUpdate({ collection, field }, {
1846
+ $max: { lastSeqNum: seedValue },
1847
+ $setOnInsert: { collection, field }
1848
+ }, { upsert: true, returnDocument: 'after', ...this._sessionOpt() });
1849
+ // Now increment atomically to get next value
1850
+ const updated = await dbConnection
1851
+ .collection(FIELD_SEQUENCES_COLLECTION)
1852
+ .findOneAndUpdate({ collection, field }, { $inc: { lastSeqNum: 1 } }, { returnDocument: 'after', ...this._sessionOpt() });
1853
+ return updated.lastSeqNum;
1854
+ }
1855
+ // Sequence exists - check if collection was cleared (reset scenario)
1856
+ if (existingMax === undefined && existingSeq.lastSeqNum > 0) {
1857
+ // Collection is empty but we have a non-zero sequence - reset to 0 then increment to 1
1858
+ await dbConnection
1859
+ .collection(FIELD_SEQUENCES_COLLECTION)
1860
+ .updateOne({ collection, field }, { $set: { lastSeqNum: 0 } }, this._sessionOpt());
1861
+ }
1862
+ // Increment and return
1863
+ const result = await dbConnection
1864
+ .collection(FIELD_SEQUENCES_COLLECTION)
1865
+ .findOneAndUpdate({ collection, field }, { $inc: { lastSeqNum: 1 } }, { returnDocument: 'after', ...this._sessionOpt() });
1866
+ return result.lastSeqNum;
1867
+ }
1868
+ /**
1869
+ * Gets the current (last) sequence value without incrementing.
1870
+ * On first use, auto-seeds from existing data in the collection.
1871
+ * If collection becomes empty, resets the sequence to 0.
1872
+ */
1873
+ async _getLastSequenceValue(dbConnection, collection, field) {
1874
+ // Check current max in the actual collection
1875
+ const existingMax = await this._findLastSequenceForKey(dbConnection.collection(collection), field);
1876
+ // Try to get existing sequence value
1877
+ const existing = await dbConnection
1878
+ .collection(FIELD_SEQUENCES_COLLECTION)
1879
+ .findOne({ collection, field }, this._sessionOpt());
1880
+ if (existing) {
1881
+ // If collection is empty (existingMax undefined), reset sequence to 0
1882
+ if (existingMax === undefined && existing.lastSeqNum > 0) {
1883
+ await dbConnection
1884
+ .collection(FIELD_SEQUENCES_COLLECTION)
1885
+ .updateOne({ collection, field }, { $set: { lastSeqNum: 0 } }, this._sessionOpt());
1886
+ return 0;
1887
+ }
1888
+ return existing.lastSeqNum;
1889
+ }
1890
+ if (existingMax !== undefined && existingMax >= 0) {
1891
+ // First time with existing data - ensure index exists and seed
1892
+ await this._ensureSequencesIndex(dbConnection);
1893
+ await dbConnection
1894
+ .collection(FIELD_SEQUENCES_COLLECTION)
1895
+ .updateOne({ collection, field }, { $setOnInsert: { lastSeqNum: existingMax } }, { upsert: true, ...this._sessionOpt() });
1896
+ return existingMax;
1897
+ }
1898
+ return 0;
1899
+ }
1900
+ /**
1901
+ * Atomically reserves a range of sequence numbers for batch operations.
1902
+ * Returns { start, end } where start is the first reserved value and end is the last.
1903
+ */
1904
+ async _reserveSequenceRange(dbConnection, collection, field, count) {
1905
+ // Check current max in the actual collection
1906
+ const existingMax = await this._findLastSequenceForKey(dbConnection.collection(collection), field);
1907
+ // Check if we have a sequence document
1908
+ const existingSeq = await dbConnection
1909
+ .collection(FIELD_SEQUENCES_COLLECTION)
1910
+ .findOne({ collection, field }, this._sessionOpt());
1911
+ if (!existingSeq) {
1912
+ // First time for this collection/field - ensure index exists
1913
+ await this._ensureSequencesIndex(dbConnection);
1914
+ const seedValue = (existingMax !== undefined && existingMax >= 1) ? existingMax : 0;
1915
+ // Use $max to handle concurrent seeding
1916
+ await dbConnection
1917
+ .collection(FIELD_SEQUENCES_COLLECTION)
1918
+ .findOneAndUpdate({ collection, field }, {
1919
+ $max: { lastSeqNum: seedValue },
1920
+ $setOnInsert: { collection, field }
1921
+ }, { upsert: true, returnDocument: 'after', ...this._sessionOpt() });
1922
+ }
1923
+ else if (existingMax === undefined && existingSeq.lastSeqNum > 0) {
1924
+ // Collection is empty but we have a non-zero sequence - reset to 0
1925
+ await dbConnection
1926
+ .collection(FIELD_SEQUENCES_COLLECTION)
1927
+ .updateOne({ collection, field }, { $set: { lastSeqNum: 0 } }, this._sessionOpt());
1928
+ }
1929
+ // Now atomically reserve the range
1930
+ const result = await dbConnection
1931
+ .collection(FIELD_SEQUENCES_COLLECTION)
1932
+ .findOneAndUpdate({ collection, field }, { $inc: { lastSeqNum: count } }, { returnDocument: 'after', ...this._sessionOpt() });
1933
+ const end = result.lastSeqNum;
1934
+ return { start: end - count + 1, end };
1935
+ }
1578
1936
  // private async _getNextCollectionUpdateSeqNo(collection: string, conn: MongoClient) {
1579
1937
  // let opts : FindOneAndUpdateOptions = {
1580
1938
  // upsert: true,
@@ -1594,22 +1952,24 @@ export class Mongo extends Db {
1594
1952
  _findSequenceKeys(object) {
1595
1953
  if (!object)
1596
1954
  return;
1597
- let seqKeys = Object.keys(object).filter(key => object[key] === 'SEQ_NEXT' || object[key] === 'SEQ_LAST');
1598
- return ((seqKeys === null || seqKeys === void 0 ? void 0 : seqKeys.length) > 0 || this.syncSupport) ? { seqKeys } : undefined;
1955
+ if (!this._hasSequenceFields(object) && !this.syncSupport)
1956
+ return;
1957
+ const seqKeys = Object.keys(object).filter(key => object[key] === 'SEQ_NEXT' || object[key] === 'SEQ_LAST');
1958
+ return { seqKeys };
1599
1959
  }
1600
1960
  async _processSequenceField(client, dbName, collection, insert, seqKeys) {
1601
1961
  assert(this.client);
1602
1962
  // if (this.syncSupport) {
1603
1963
  // insert._csq = (await this._getNextCollectionUpdateSeqNo(collection, client));
1604
1964
  // }
1605
- for await (let seqKey of (seqKeys === null || seqKeys === void 0 ? void 0 : seqKeys.seqKeys) || []) {
1606
- let last = await this._findLastSequenceForKey(client.db(dbName).collection(collection), seqKey);
1607
- if (last === undefined) {
1608
- await this.createCollection(collection);
1609
- last = 0;
1965
+ const db = client.db(dbName);
1966
+ for (const seqKey of (seqKeys === null || seqKeys === void 0 ? void 0 : seqKeys.seqKeys) || []) {
1967
+ if (insert[seqKey] === 'SEQ_LAST') {
1968
+ insert[seqKey] = await this._getLastSequenceValue(db, collection, seqKey);
1969
+ }
1970
+ else {
1971
+ insert[seqKey] = await this._getNextSequenceValue(db, collection, seqKey);
1610
1972
  }
1611
- let next = insert[seqKey] === 'SEQ_LAST' ? last : last + 1;
1612
- insert[seqKey] = next;
1613
1973
  }
1614
1974
  return insert;
1615
1975
  }
@@ -1618,35 +1978,45 @@ export class Mongo extends Db {
1618
1978
  assert(connection);
1619
1979
  if (!(inserts === null || inserts === void 0 ? void 0 : inserts.length))
1620
1980
  return;
1981
+ // Collect all unique sequence keys across all inserts
1621
1982
  let seqKeysSet = new Set();
1622
1983
  for (let insert of inserts) {
1623
1984
  let spec = this._findSequenceKeys(insert);
1624
1985
  spec === null || spec === void 0 ? void 0 : spec.seqKeys.forEach(key => seqKeysSet.add(key));
1625
1986
  }
1626
1987
  let seqKeys = Array.from(seqKeysSet);
1627
- // let seq: number = 0;
1628
- // if (this.syncSupport) seq = await this._getNextCollectionUpdateSeqNo(collection, connection);
1629
1988
  if (!seqKeys.length)
1630
1989
  return inserts;
1631
- // Fetch all sequence last values in parallel
1632
- const coll = connection.db(dbName).collection(collection);
1633
- const lastValues = await Promise.all(seqKeys.map(seqKey => this._findLastSequenceForKey(coll, seqKey)));
1634
- // Check if collection needs to be created (any undefined value)
1635
- if (lastValues.some(v => v === undefined)) {
1636
- try {
1637
- await this.createCollection(collection);
1990
+ const db = connection.db(dbName);
1991
+ // Process each sequence key
1992
+ for (const seqKey of seqKeys) {
1993
+ // Count SEQ_NEXT occurrences to reserve the range
1994
+ let seqNextCount = 0;
1995
+ for (const insert of inserts) {
1996
+ if (insert[seqKey] === 'SEQ_NEXT')
1997
+ seqNextCount++;
1998
+ }
1999
+ // Get current value and reserve range for SEQ_NEXT if needed
2000
+ let currentValue;
2001
+ if (seqNextCount > 0) {
2002
+ const range = await this._reserveSequenceRange(db, collection, seqKey, seqNextCount);
2003
+ // currentValue starts at one before the range start (since SEQ_LAST gets current, SEQ_NEXT gets next)
2004
+ currentValue = range.start - 1;
1638
2005
  }
1639
- catch { /* collection may already exist */ }
1640
- }
1641
- // Build a map of seqKey -> last value
1642
- const seqKeyToLast = new Map(seqKeys.map((key, idx) => { var _a; return [key, (_a = lastValues[idx]) !== null && _a !== void 0 ? _a : 0]; }));
1643
- // Process inserts for all sequence keys
1644
- for (const insert of inserts) {
1645
- for (const seqKey of seqKeys) {
1646
- const last = seqKeyToLast.get(seqKey);
1647
- const next = insert[seqKey] === 'SEQ_LAST' ? last : last + 1;
1648
- insert[seqKey] = next;
1649
- seqKeyToLast.set(seqKey, next);
2006
+ else {
2007
+ // Only SEQ_LAST values, just get the current value
2008
+ currentValue = await this._getLastSequenceValue(db, collection, seqKey);
2009
+ }
2010
+ // Process inserts in order - SEQ_LAST gets current, SEQ_NEXT increments and gets new value
2011
+ for (const insert of inserts) {
2012
+ const val = insert[seqKey];
2013
+ if (val === 'SEQ_LAST') {
2014
+ insert[seqKey] = currentValue;
2015
+ }
2016
+ else if (val === 'SEQ_NEXT') {
2017
+ currentValue++;
2018
+ insert[seqKey] = currentValue;
2019
+ }
1650
2020
  }
1651
2021
  }
1652
2022
  return inserts;
@@ -1677,11 +2047,11 @@ export class Mongo extends Db {
1677
2047
  a._id = wholerecord._id;
1678
2048
  return a;
1679
2049
  }
1680
- _shouldAuditCollection(db, col, audited = this.auditedCollections) {
2050
+ _shouldAuditCollection(db, col) {
1681
2051
  if (!this.auditing)
1682
2052
  return false;
1683
2053
  const fullName = ((db ? db + "." : "") + (col || "")).toLowerCase();
1684
- return audited.some(m => fullName.includes(m.toLowerCase()));
2054
+ return this.auditedCollectionsLower.some(m => fullName.includes(m));
1685
2055
  }
1686
2056
  async _publishAndAudit(operation, db, collection, dataToPublish, noEmit) {
1687
2057
  if (!dataToPublish._id && !["deleteMany", "updateMany"].includes(operation))
@@ -1875,8 +2245,13 @@ export class Mongo extends Db {
1875
2245
  r = r.collation(opts.collation);
1876
2246
  return r;
1877
2247
  }
1878
- async _processUpdateObject(update) {
1879
- await this._processHashedKeys(update);
2248
+ _hasHashedKeys(obj) {
2249
+ return Object.keys(obj).some(startsWithHashedPrefix);
2250
+ }
2251
+ _hasSequenceFields(obj) {
2252
+ return Object.values(obj).some(v => v === 'SEQ_NEXT' || v === 'SEQ_LAST');
2253
+ }
2254
+ _processUpdateObject(update) {
1880
2255
  for (let k in update) {
1881
2256
  let key = k;
1882
2257
  const keyStr = String(key);
@@ -1919,16 +2294,27 @@ export class Mongo extends Db {
1919
2294
  }
1920
2295
  return update;
1921
2296
  }
1922
- async _processHashedKeys(update) {
1923
- // Pre-filter hashed keys before async processing
1924
- const hashedKeys = Object.keys(update).filter(startsWithHashedPrefix);
1925
- // Process all hashed keys in parallel
2297
+ async _processHashedKeys(obj) {
2298
+ const hashedKeys = Object.keys(obj).filter(startsWithHashedPrefix);
1926
2299
  await Promise.all(hashedKeys.map(async (key) => {
1927
2300
  const salt = await bcrypt.genSalt(saltRounds);
1928
- const hash = await bcrypt.hash(update[key], salt);
1929
- update[key] = { salt, hash };
2301
+ const hash = await bcrypt.hash(obj[key], salt);
2302
+ obj[key] = { salt, hash };
1930
2303
  }));
1931
2304
  }
2305
+ /**
2306
+ * Removes fields starting with `__` from an object (mutates in place).
2307
+ * Used to sanitize input on insert/update operations, preventing clients
2308
+ * from writing internal/reserved fields directly to the database.
2309
+ * Fields with `__hashed__` prefix are preserved (used for password hashing).
2310
+ */
2311
+ _stripDoubleUnderscoreKeys(obj) {
2312
+ for (const key in obj) {
2313
+ if (startsWithDoubleUnderscore(key) && !startsWithHashedPrefix(key)) {
2314
+ delete obj[key];
2315
+ }
2316
+ }
2317
+ }
1932
2318
  _processReturnedObject(ret) {
1933
2319
  if (!ret)
1934
2320
  return ret;