@frogfish/k2db 2.0.6 → 3.0.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,14 +1,161 @@
1
1
  // src/db.ts
2
- import { K2Error, ServiceError } from "@frogfish/k2error"; // Keep the existing error structure
2
+ import { K2Error, ServiceError, wrap } from "@frogfish/k2error"; // Keep the existing error structure
3
3
  import { MongoClient, } from "mongodb";
4
- import { v4 as uuidv4 } from "uuid";
5
- import debugLib from "debug";
4
+ import { randomBytes, createHash } from "crypto";
5
+ // import debugLib from "debug";
6
+ import { Topic } from '@frogfish/ratatouille';
6
7
  import { z } from "zod";
7
- const debug = debugLib("k2:db");
8
+ // const debug = debugLib("k2:db");
9
+ const debug = Topic('k2db#random');
10
+ function _hashSecret(secret) {
11
+ return createHash("sha256").update(secret).digest("hex");
12
+ }
13
+ // ---- Shared MongoClient pool (per cluster+auth), reused across DB names ----
14
+ const _clientByKey = new Map();
15
+ const _connectingByKey = new Map();
16
+ const _refCountByKey = new Map();
17
+ function _hostsKey(hosts) {
18
+ const hs = hosts ?? [];
19
+ return hs
20
+ .map((h) => `${h.host}:${h.port ?? ""}`)
21
+ .sort()
22
+ .join(",");
23
+ }
24
+ /**
25
+ * Cache key for a MongoClient pool. Intentionally excludes `conf.name` (db name),
26
+ * so multiple DBs share the same connection pool.
27
+ */
28
+ function _clientCacheKey(conf) {
29
+ const user = conf.user ?? "";
30
+ const pass = conf.password ?? "";
31
+ const passKey = pass ? `sha256:${_hashSecret(pass)}` : "";
32
+ const authSource = user && pass ? (conf.authSource ?? "admin") : "";
33
+ const rs = conf.replicaset ?? "";
34
+ const hosts = _hostsKey(conf.hosts);
35
+ return `hosts=${hosts}|user=${user}|pass=${passKey}|authSource=${authSource}|rs=${rs}`;
36
+ }
37
+ async function _acquireClient(key, uri, options) {
38
+ const existing = _clientByKey.get(key);
39
+ if (existing)
40
+ return existing;
41
+ const inflight = _connectingByKey.get(key);
42
+ if (inflight)
43
+ return inflight;
44
+ const p = MongoClient.connect(uri, options)
45
+ .then((client) => {
46
+ _clientByKey.set(key, client);
47
+ _connectingByKey.delete(key);
48
+ return client;
49
+ })
50
+ .catch((err) => {
51
+ _connectingByKey.delete(key);
52
+ throw err;
53
+ });
54
+ _connectingByKey.set(key, p);
55
+ return p;
56
+ }
57
+ function _increfClient(key) {
58
+ _refCountByKey.set(key, (_refCountByKey.get(key) ?? 0) + 1);
59
+ }
60
+ async function _decrefClient(key) {
61
+ const next = (_refCountByKey.get(key) ?? 0) - 1;
62
+ if (next <= 0) {
63
+ _refCountByKey.delete(key);
64
+ const client = _clientByKey.get(key);
65
+ if (client) {
66
+ _clientByKey.delete(key);
67
+ try {
68
+ await client.close();
69
+ }
70
+ catch (err) {
71
+ // Best-effort shutdown: never throw from release/close paths.
72
+ debug(`MongoClient close failed for key=${key}: ${err instanceof Error ? err.message : String(err)}`);
73
+ }
74
+ }
75
+ return;
76
+ }
77
+ _refCountByKey.set(key, next);
78
+ }
79
+ // ---- End shared MongoClient pool helpers ----
80
+ // Crockford Base32 alphabet (no I, L, O, U)
81
+ const CROCKFORD32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
82
+ /**
83
+ * Generates a UUIDv7 (time-ordered) and encodes it as Crockford Base32 with hyphens.
84
+ * Format: 26 base32 chars grouped as 8-4-4-4-6 (total 26)
85
+ */
86
+ function uuidv7Base32Hyphenated() {
87
+ // 1) Build UUIDv7 bytes
88
+ // Layout per RFC: time_low(32) | time_mid(16) | time_hi_and_version(16) | clock_seq(16) | node(48)
89
+ // Encode 60-bit ms timestamp across time_* fields, version 7, RFC4122 variant in clock_seq_hi.
90
+ const ts = BigInt(Date.now()); // milliseconds
91
+ const timeLow = Number((ts >> 28n) & 0xffffffffn);
92
+ const timeMid = Number((ts >> 12n) & 0xffffn);
93
+ const timeHi = Number(ts & 0xfffn); // lower 12 bits
94
+ const bytes = new Uint8Array(16);
95
+ // time_low (big-endian)
96
+ bytes[0] = (timeLow >>> 24) & 0xff;
97
+ bytes[1] = (timeLow >>> 16) & 0xff;
98
+ bytes[2] = (timeLow >>> 8) & 0xff;
99
+ bytes[3] = timeLow & 0xff;
100
+ // time_mid (big-endian)
101
+ bytes[4] = (timeMid >>> 8) & 0xff;
102
+ bytes[5] = timeMid & 0xff;
103
+ // time_high_and_version: version 7 in high nibble + top 4 bits of timeHi
104
+ bytes[6] = 0x70 | ((timeHi >>> 8) & 0x0f); // 0x7- version
105
+ bytes[7] = timeHi & 0xff;
106
+ // clock_seq + node: 8 random bytes; set RFC4122 variant (10xxxxxx)
107
+ const rnd = randomBytes(8);
108
+ bytes.set(rnd, 8);
109
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // set variant 10xxxxxx
110
+ // 2) Encode as Crockford Base32 (26 chars). 128 bits -> 26*5 bits (pad 2 high bits)
111
+ let value = 0n;
112
+ for (let i = 0; i < 16; i++) {
113
+ value = (value << 8n) | BigInt(bytes[i]);
114
+ }
115
+ value <<= 2n; // pad to 130 bits so we can take 26 groups cleanly
116
+ let encoded = "";
117
+ for (let i = 25; i >= 0; i--) {
118
+ const idx = Number((value >> BigInt(i * 5)) & 0x1fn);
119
+ encoded += CROCKFORD32[idx];
120
+ }
121
+ // 3) Insert hyphens in groups: 8-4-4-4-6
122
+ return (encoded.slice(0, 8) +
123
+ "-" +
124
+ encoded.slice(8, 12) +
125
+ "-" +
126
+ encoded.slice(12, 16) +
127
+ "-" +
128
+ encoded.slice(16, 20) +
129
+ "-" +
130
+ encoded.slice(20));
131
+ }
132
+ /**
133
+ * Test helper: fully reset the shared MongoClient pool.
134
+ *
135
+ * Not for production usage; intended for test runners to clean up
136
+ * between suites without restarting the process.
137
+ */
138
+ export async function resetSharedMongoClientsForTests() {
139
+ const entries = Array.from(_clientByKey.entries());
140
+ for (const [key, client] of entries) {
141
+ try {
142
+ await client.close();
143
+ }
144
+ catch (err) {
145
+ debug(`MongoClient close failed during reset for key=${key}: ${err instanceof Error ? err.message : String(err)}`);
146
+ }
147
+ }
148
+ _clientByKey.clear();
149
+ _connectingByKey.clear();
150
+ _refCountByKey.clear();
151
+ }
8
152
  export class K2DB {
9
153
  conf;
10
154
  db;
11
155
  connection;
156
+ clientKey;
157
+ initialized = false;
158
+ initPromise;
12
159
  schemas = new Map();
13
160
  constructor(conf) {
14
161
  this.conf = conf;
@@ -17,22 +164,41 @@ export class K2DB {
17
164
  * Initializes the MongoDB connection.
18
165
  */
19
166
  async init() {
20
- // Build URI and options
21
- const { uri, options } = this.buildMongoUri();
22
- const dbName = this.conf.name;
23
- // Mask sensitive information in logs
24
- const safeConnectUrl = uri.replace(/\/\/.*?:.*?@/, "//*****:*****@");
25
- debug(`Connecting to MongoDB: ${safeConnectUrl}`);
26
- try {
27
- // 8. Establish MongoDB connection
28
- this.connection = await MongoClient.connect(uri, options);
29
- this.db = this.connection.db(dbName);
30
- debug("Successfully connected to MongoDB");
31
- }
32
- catch (err) {
33
- // 9. Handle connection error
34
- throw new K2Error(ServiceError.SYSTEM_ERROR, `Failed to connect to MongoDB: ${err.message}`, "sys_mdb_init", this.normalizeError(err));
35
- }
167
+ if (this.initialized)
168
+ return;
169
+ if (this.initPromise)
170
+ return this.initPromise;
171
+ this.initPromise = (async () => {
172
+ // Build URI and options
173
+ const { uri, options } = this.buildMongoUri();
174
+ // Mask sensitive information in logs
175
+ const safeConnectUrl = uri.replace(/\/\/.*?:.*?@/, "//*****:*****@");
176
+ debug(`Connecting to MongoDB: ${safeConnectUrl}`);
177
+ try {
178
+ // Establish (or reuse) a shared MongoClient pool per cluster+auth (NOT per db name)
179
+ const key = _clientCacheKey(this.conf);
180
+ const client = await _acquireClient(key, uri, options);
181
+ this.connection = client;
182
+ this.db = this.connection.db(this.conf.name);
183
+ this.clientKey = key;
184
+ if (!this.initialized) {
185
+ _increfClient(key);
186
+ this.initialized = true;
187
+ }
188
+ debug("Successfully connected to MongoDB");
189
+ }
190
+ catch (err) {
191
+ // Handle connection error
192
+ const msg = err instanceof Error
193
+ ? `Failed to connect to MongoDB: ${err.message}`
194
+ : `Failed to connect to MongoDB: ${String(err)}`;
195
+ throw wrap(err, ServiceError.SERVICE_UNAVAILABLE, "sys_mdb_init", msg);
196
+ }
197
+ })().finally(() => {
198
+ // Allow retry after failure; once initialized, subsequent calls return early.
199
+ this.initPromise = undefined;
200
+ });
201
+ return this.initPromise;
36
202
  }
37
203
  /**
38
204
  * Build a robust MongoDB URI based on config (supports SRV and standard).
@@ -50,7 +216,7 @@ export class K2DB {
50
216
  let uri;
51
217
  if (useSrv) {
52
218
  const host = this.conf.hosts[0].host;
53
- uri = `mongodb+srv://${auth}${host}/${dbName}?retryWrites=true&w=majority`;
219
+ uri = `mongodb+srv://${auth}${host}/?retryWrites=true&w=majority`;
54
220
  }
55
221
  else {
56
222
  const hostList = this.conf.hosts
@@ -59,11 +225,16 @@ export class K2DB {
59
225
  const params = ["retryWrites=true", "w=majority"];
60
226
  if (this.conf.replicaset)
61
227
  params.push(`replicaSet=${this.conf.replicaset}`);
62
- uri = `mongodb://${auth}${hostList}/${dbName}?${params.join("&")}`;
228
+ uri = `mongodb://${auth}${hostList}/?${params.join("&")}`;
63
229
  }
230
+ // Determine authSource based on user and password presence
231
+ const authSource = this.conf.user && this.conf.password
232
+ ? this.conf.authSource ?? "admin"
233
+ : undefined;
64
234
  const options = {
65
235
  connectTimeoutMS: 2000,
66
236
  serverSelectionTimeoutMS: 2000,
237
+ ...(authSource ? { authSource } : {}),
67
238
  };
68
239
  return { uri, options };
69
240
  }
@@ -84,6 +255,7 @@ export class K2DB {
84
255
  hosts,
85
256
  user: get("USER"),
86
257
  password: get("PASSWORD"),
258
+ authSource: get("AUTH_SOURCE"),
87
259
  replicaset: get("REPLICASET"),
88
260
  };
89
261
  const slow = get("SLOW_MS");
@@ -102,16 +274,13 @@ export class K2DB {
102
274
  return collection;
103
275
  }
104
276
  catch (err) {
105
- // If the error is already an K2Error, rethrow it
106
- if (err instanceof K2Error) {
107
- throw err;
108
- }
109
- throw new K2Error(ServiceError.SYSTEM_ERROR, `Error getting collection: ${collectionName}`, "sys_mdb_gc", this.normalizeError(err));
277
+ throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_gc", `Error getting collection: ${collectionName}`);
110
278
  }
111
279
  }
112
280
  async get(collectionName, uuid) {
281
+ const id = K2DB.normalizeId(uuid);
113
282
  const res = await this.findOne(collectionName, {
114
- _uuid: uuid,
283
+ _uuid: id,
115
284
  _deleted: { $ne: true },
116
285
  });
117
286
  if (!res) {
@@ -130,8 +299,9 @@ export class K2DB {
130
299
  const collection = await this.getCollection(collectionName);
131
300
  const projection = {};
132
301
  // Exclude soft-deleted documents by default unless caller specifies otherwise
302
+ const normalizedCriteria = K2DB.normalizeCriteriaIds(criteria || {});
133
303
  const query = {
134
- ...criteria,
304
+ ...normalizedCriteria,
135
305
  ...(criteria && Object.prototype.hasOwnProperty.call(criteria, "_deleted")
136
306
  ? {}
137
307
  : { _deleted: { $ne: true } }),
@@ -150,7 +320,7 @@ export class K2DB {
150
320
  return null;
151
321
  }
152
322
  catch (err) {
153
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Error finding document", "sys_mdb_fo", this.normalizeError(err));
323
+ throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_fo", "Error finding document");
154
324
  }
155
325
  }
156
326
  /**
@@ -164,7 +334,8 @@ export class K2DB {
164
334
  async find(collectionName, filter, params = {}, skip = 0, limit = 100) {
165
335
  const collection = await this.getCollection(collectionName);
166
336
  // Ensure filter is valid, defaulting to an empty object
167
- const criteria = { ...(filter || {}) };
337
+ let criteria = { ...(filter || {}) };
338
+ criteria = K2DB.normalizeCriteriaIds(criteria);
168
339
  // Handle the _deleted field if params specify not to include deleted documents
169
340
  if (!params?.includeDeleted && !Object.prototype.hasOwnProperty.call(criteria, "_deleted")) {
170
341
  if (params?.deleted === true) {
@@ -219,7 +390,7 @@ export class K2DB {
219
390
  return result;
220
391
  }
221
392
  catch (err) {
222
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Error executing find query", "sys_mdb_find_error", this.normalizeError(err));
393
+ throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_find_error", "Error executing find query");
223
394
  }
224
395
  }
225
396
  /**
@@ -257,7 +428,7 @@ export class K2DB {
257
428
  return data.map((doc) => doc);
258
429
  }
259
430
  catch (err) {
260
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Aggregation failed", "sys_mdb_ag", this.normalizeError(err));
431
+ throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_ag", "Aggregation failed");
261
432
  }
262
433
  }
263
434
  /**
@@ -358,8 +529,8 @@ export class K2DB {
358
529
  }
359
530
  const collection = await this.getCollection(collectionName);
360
531
  const timestamp = Date.now();
361
- // Generate a new UUID
362
- const newUuid = uuidv4();
532
+ // Generate a new UUIDv7 encoded as Crockford Base32 with hyphens
533
+ const newUuid = uuidv7Base32Hyphenated();
363
534
  // Remove reserved fields from user data, then validate/transform via schema if present
364
535
  const safeData = K2DB.stripReservedFields(data);
365
536
  const validated = this.applySchema(collectionName, safeData, /*partial*/ false);
@@ -372,7 +543,7 @@ export class K2DB {
372
543
  _uuid: newUuid,
373
544
  };
374
545
  try {
375
- const result = await this.runTimed("insertOne", { collectionName }, async () => await collection.insertOne(document));
546
+ await this.runTimed("insertOne", { collectionName }, async () => await collection.insertOne(document));
376
547
  return { id: document._uuid };
377
548
  }
378
549
  catch (err) {
@@ -384,7 +555,7 @@ export class K2DB {
384
555
  // Log the error details for debugging
385
556
  debug(`Was trying to insert into collection ${collectionName}, data: ${JSON.stringify(document)}`);
386
557
  debug(err);
387
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Error saving object to database", "sys_mdb_sav", this.normalizeError(err));
558
+ throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_sav", "Error saving object to database");
388
559
  }
389
560
  }
390
561
  /**
@@ -411,6 +582,7 @@ export class K2DB {
411
582
  if (deletedFlag !== undefined) {
412
583
  values._deleted = deletedFlag;
413
584
  }
585
+ criteria = K2DB.normalizeCriteriaIds(criteria || {});
414
586
  criteria = {
415
587
  ...criteria,
416
588
  _deleted: { $ne: true },
@@ -422,7 +594,7 @@ export class K2DB {
422
594
  };
423
595
  }
424
596
  catch (err) {
425
- throw new K2Error(ServiceError.SYSTEM_ERROR, `Error updating ${collectionName}`, "sys_mdb_update1", this.normalizeError(err));
597
+ throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_update1", `Error updating ${collectionName}`);
426
598
  }
427
599
  }
428
600
  /**
@@ -434,6 +606,7 @@ export class K2DB {
434
606
  * @param replace - If true, replaces the entire document (PUT), otherwise patches (PATCH).
435
607
  */
436
608
  async update(collectionName, id, data, replace = false) {
609
+ id = K2DB.normalizeId(id);
437
610
  this.validateCollectionName(collectionName);
438
611
  const collection = await this.getCollection(collectionName);
439
612
  data = K2DB.stripReservedFields(data);
@@ -469,11 +642,7 @@ export class K2DB {
469
642
  return { updated: res.modifiedCount };
470
643
  }
471
644
  catch (err) {
472
- if (err instanceof K2Error) {
473
- throw err;
474
- }
475
- // Catch any other unhandled errors and throw a system error
476
- throw new K2Error(ServiceError.SYSTEM_ERROR, `Error updating ${collectionName}`, "sys_mdb_update_error", this.normalizeError(err));
645
+ throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_update_error", `Error updating ${collectionName}`);
477
646
  }
478
647
  }
479
648
  /**
@@ -484,13 +653,13 @@ export class K2DB {
484
653
  async deleteAll(collectionName, criteria) {
485
654
  this.validateCollectionName(collectionName);
486
655
  try {
487
- let result = await this.updateAll(collectionName, criteria, {
656
+ const result = await this.updateAll(collectionName, criteria, {
488
657
  _deleted: true,
489
658
  });
490
659
  return { deleted: result.updated };
491
660
  }
492
661
  catch (err) {
493
- throw new K2Error(ServiceError.SYSTEM_ERROR, `Error updating ${collectionName}`, "sys_mdb_deleteall_update", this.normalizeError(err));
662
+ throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_deleteall_update", `Error updating ${collectionName}`);
494
663
  }
495
664
  }
496
665
  /**
@@ -499,6 +668,7 @@ export class K2DB {
499
668
  * @param id - UUID of the document.
500
669
  */
501
670
  async delete(collectionName, id) {
671
+ id = K2DB.normalizeId(id);
502
672
  try {
503
673
  // Call deleteAll to soft delete the document by UUID
504
674
  const result = await this.deleteAll(collectionName, { _uuid: id });
@@ -517,11 +687,7 @@ export class K2DB {
517
687
  }
518
688
  }
519
689
  catch (err) {
520
- // Preserve existing K2Error classifications (e.g., NOT_FOUND)
521
- if (err instanceof K2Error) {
522
- throw err;
523
- }
524
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Error removing object from collection", "sys_mdb_remove_upd", this.normalizeError(err));
690
+ throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_remove_upd", "Error removing object from collection");
525
691
  }
526
692
  }
527
693
  /**
@@ -530,6 +696,7 @@ export class K2DB {
530
696
  * @param id - UUID of the document.
531
697
  */
532
698
  async purge(collectionName, id) {
699
+ id = K2DB.normalizeId(id);
533
700
  const collection = await this.getCollection(collectionName);
534
701
  try {
535
702
  const item = await this.runTimed("findOne", { collectionName, _uuid: id, _deleted: true }, async () => await collection.findOne({
@@ -543,10 +710,7 @@ export class K2DB {
543
710
  return { id };
544
711
  }
545
712
  catch (err) {
546
- if (err instanceof K2Error) {
547
- throw err;
548
- }
549
- throw new K2Error(ServiceError.SYSTEM_ERROR, `Error purging item with id: ${id}`, "sys_mdb_pg", this.normalizeError(err));
713
+ throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_pg", `Error purging item with id: ${id}`);
550
714
  }
551
715
  }
552
716
  /**
@@ -571,7 +735,7 @@ export class K2DB {
571
735
  return { purged: res.deletedCount ?? 0 };
572
736
  }
573
737
  catch (err) {
574
- throw new K2Error(ServiceError.SYSTEM_ERROR, 'Error purging deleted items by age', 'sys_mdb_purge_older', this.normalizeError(err));
738
+ throw wrap(err, ServiceError.SYSTEM_ERROR, 'sys_mdb_purge_older', 'Error purging deleted items by age');
575
739
  }
576
740
  }
577
741
  /**
@@ -581,7 +745,8 @@ export class K2DB {
581
745
  */
582
746
  async restore(collectionName, criteria) {
583
747
  const collection = await this.getCollection(collectionName);
584
- const query = { ...(criteria || {}), _deleted: true };
748
+ const crit = K2DB.normalizeCriteriaIds(criteria || {});
749
+ const query = { ...crit, _deleted: true };
585
750
  try {
586
751
  const res = await this.runTimed("updateMany", { collectionName, query }, async () => await collection.updateMany(query, {
587
752
  // Restoring is a data change: flip _deleted and bump _updated
@@ -590,7 +755,7 @@ export class K2DB {
590
755
  return { status: "restored", modified: res.modifiedCount };
591
756
  }
592
757
  catch (err) {
593
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Error restoring a deleted item", "sys_mdb_pres", this.normalizeError(err));
758
+ throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_pres", "Error restoring a deleted item");
594
759
  }
595
760
  }
596
761
  /**
@@ -601,8 +766,9 @@ export class K2DB {
601
766
  async count(collectionName, criteria) {
602
767
  const collection = await this.getCollection(collectionName);
603
768
  try {
769
+ const norm = K2DB.normalizeCriteriaIds(criteria || {});
604
770
  const query = {
605
- ...criteria,
771
+ ...norm,
606
772
  ...(criteria && Object.prototype.hasOwnProperty.call(criteria, "_deleted")
607
773
  ? {}
608
774
  : { _deleted: { $ne: true } }),
@@ -611,7 +777,7 @@ export class K2DB {
611
777
  return { count: cnt };
612
778
  }
613
779
  catch (err) {
614
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Error counting objects with given criteria", "sys_mdb_cn", this.normalizeError(err));
780
+ throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_cn", "Error counting objects with given criteria");
615
781
  }
616
782
  }
617
783
  /**
@@ -625,7 +791,7 @@ export class K2DB {
625
791
  return { status: "ok" };
626
792
  }
627
793
  catch (err) {
628
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Error dropping collection", "sys_mdb_drop", this.normalizeError(err));
794
+ throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_drop", "Error dropping collection");
629
795
  }
630
796
  }
631
797
  /**
@@ -634,6 +800,8 @@ export class K2DB {
634
800
  */
635
801
  static sanitiseCriteria(criteria) {
636
802
  if (criteria.$match) {
803
+ // Normalize any _uuid values in the match object to uppercase
804
+ criteria.$match = K2DB.normalizeCriteriaIds(criteria.$match);
637
805
  for (const key of Object.keys(criteria.$match)) {
638
806
  if (typeof criteria.$match[key] !== "string") {
639
807
  criteria.$match[key] = K2DB.sanitiseCriteria({
@@ -649,6 +817,45 @@ export class K2DB {
649
817
  }
650
818
  return criteria;
651
819
  }
820
+ /** Recursively uppercases any values for fields named `_uuid` within a query object. */
821
+ static normalizeCriteriaIds(obj) {
822
+ if (!obj || typeof obj !== "object")
823
+ return obj;
824
+ if (Array.isArray(obj))
825
+ return obj.map((v) => K2DB.normalizeCriteriaIds(v));
826
+ const out = Array.isArray(obj) ? [] : { ...obj };
827
+ for (const [k, v] of Object.entries(obj)) {
828
+ if (k === "_uuid") {
829
+ out[k] = K2DB.normalizeUuidField(v);
830
+ }
831
+ else if (v && typeof v === "object") {
832
+ out[k] = K2DB.normalizeCriteriaIds(v);
833
+ }
834
+ else if (Array.isArray(v)) {
835
+ out[k] = v.map((x) => K2DB.normalizeCriteriaIds(x));
836
+ }
837
+ else {
838
+ out[k] = v;
839
+ }
840
+ }
841
+ return out;
842
+ }
843
+ /** Uppercase helper for `_uuid` field supporting operators like $in/$nin/$eq/$ne and arrays. */
844
+ static normalizeUuidField(val) {
845
+ if (typeof val === "string")
846
+ return val.toUpperCase();
847
+ if (Array.isArray(val))
848
+ return val.map((x) => (typeof x === "string" ? x.toUpperCase() : x));
849
+ if (val && typeof val === "object") {
850
+ const out = { ...val };
851
+ for (const op of ["$in", "$nin", "$eq", "$ne", "$all"]) {
852
+ if (op in out)
853
+ out[op] = K2DB.normalizeUuidField(out[op]);
854
+ }
855
+ return out;
856
+ }
857
+ return val;
858
+ }
652
859
  /** Strip any user-provided fields that start with '_' (reserved). */
653
860
  static stripReservedFields(obj) {
654
861
  const out = {};
@@ -658,6 +865,18 @@ export class K2DB {
658
865
  }
659
866
  return out;
660
867
  }
868
+ /** True if string matches K2 ID format (Crockford Base32, 8-4-4-4-6, uppercase). */
869
+ static isK2ID(id) {
870
+ if (typeof id !== "string")
871
+ return false;
872
+ const s = id.trim().toUpperCase();
873
+ const CROCK_RE = /^[0-9A-HJKMNPQRSTVWXYZ]{8}-[0-9A-HJKMNPQRSTVWXYZ]{4}-[0-9A-HJKMNPQRSTVWXYZ]{4}-[0-9A-HJKMNPQRSTVWXYZ]{4}-[0-9A-HJKMNPQRSTVWXYZ]{6}$/;
874
+ return CROCK_RE.test(s);
875
+ }
876
+ /** Uppercase incoming IDs for case-insensitive lookups. */
877
+ static normalizeId(id) {
878
+ return id.toUpperCase();
879
+ }
661
880
  /**
662
881
  * Run an async DB operation with timing, slow logging, and hooks.
663
882
  */
@@ -723,7 +942,7 @@ export class K2DB {
723
942
  }
724
943
  catch (error) {
725
944
  await session.abortTransaction();
726
- throw this.normalizeError(error);
945
+ throw wrap(error, ServiceError.BAD_GATEWAY, "sys_mdb_txn", "Transaction failed");
727
946
  }
728
947
  finally {
729
948
  session.endSession();
@@ -742,21 +961,29 @@ export class K2DB {
742
961
  debug(`Index created on ${collectionName}: ${JSON.stringify(indexSpec)}`);
743
962
  }
744
963
  catch (err) {
745
- throw new K2Error(ServiceError.SYSTEM_ERROR, `Error creating index on ${collectionName}`, "sys_mdb_idx", this.normalizeError(err));
964
+ throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_idx", `Error creating index on ${collectionName}`);
746
965
  }
747
966
  }
748
967
  /**
749
968
  * Releases the MongoDB connection.
750
969
  */
751
970
  async release() {
752
- await this.connection.close();
971
+ if (this.initialized && this.clientKey) {
972
+ const key = this.clientKey;
973
+ this.initialized = false;
974
+ this.clientKey = undefined;
975
+ await _decrefClient(key);
976
+ debug("MongoDB connection released");
977
+ return;
978
+ }
753
979
  debug("MongoDB connection released");
754
980
  }
755
981
  /**
756
982
  * Closes the MongoDB connection.
757
983
  */
758
984
  close() {
759
- this.connection.close();
985
+ // Fire-and-forget async release (shared pool is refcounted)
986
+ void this.release();
760
987
  }
761
988
  /**
762
989
  * Drops the entire database.
@@ -767,7 +994,7 @@ export class K2DB {
767
994
  debug("Database dropped successfully");
768
995
  }
769
996
  catch (err) {
770
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Error dropping database", "sys_mdb_drop_db", this.normalizeError(err));
997
+ throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_drop_db", "Error dropping database");
771
998
  }
772
999
  }
773
1000
  /**
@@ -802,14 +1029,6 @@ export class K2DB {
802
1029
  return false;
803
1030
  }
804
1031
  }
805
- /**
806
- * Utility to normalize the error type.
807
- * @param err - The caught error of type `unknown`.
808
- * @returns A normalized error of type `Error`.
809
- */
810
- normalizeError(err) {
811
- return err instanceof Error ? err : new Error(String(err));
812
- }
813
1032
  // ===== Versioning helpers and APIs =====
814
1033
  /** Name of the history collection for a given collection. */
815
1034
  historyName(collectionName) {
@@ -846,7 +1065,7 @@ export class K2DB {
846
1065
  }
847
1066
  const parsed = s.safeParse(data);
848
1067
  if (!parsed.success) {
849
- throw new K2Error(ServiceError.BAD_REQUEST, "Validation failed", "sys_mdb_schema_validation", new Error(parsed.error.message));
1068
+ throw new K2Error(ServiceError.VALIDATION_ERROR, parsed.error.message, "sys_mdb_schema_validation", parsed.error);
850
1069
  }
851
1070
  return parsed.data;
852
1071
  }
@@ -862,6 +1081,7 @@ export class K2DB {
862
1081
  }
863
1082
  /** Compute the next version number for a document. */
864
1083
  async nextVersion(collectionName, id) {
1084
+ id = K2DB.normalizeId(id);
865
1085
  const hc = await this.getHistoryCollection(collectionName);
866
1086
  const last = await hc
867
1087
  .find({ _uuid: id })
@@ -888,6 +1108,7 @@ export class K2DB {
888
1108
  * If maxVersions is provided, prunes oldest snapshots beyond that number.
889
1109
  */
890
1110
  async updateVersioned(collectionName, id, data, replace = false, maxVersions) {
1111
+ id = K2DB.normalizeId(id);
891
1112
  // Get current doc (excludes deleted) and snapshot it
892
1113
  const current = await this.get(collectionName, id);
893
1114
  await this.ensureHistoryIndexes(collectionName);
@@ -916,6 +1137,7 @@ export class K2DB {
916
1137
  }
917
1138
  /** List versions (latest first). */
918
1139
  async listVersions(collectionName, id, skip = 0, limit = 20) {
1140
+ id = K2DB.normalizeId(id);
919
1141
  const hc = await this.getHistoryCollection(collectionName);
920
1142
  const rows = await hc
921
1143
  .find({ _uuid: id })
@@ -928,6 +1150,7 @@ export class K2DB {
928
1150
  }
929
1151
  /** Revert the current document to a specific historical version (preserves metadata). */
930
1152
  async revertToVersion(collectionName, id, version) {
1153
+ id = K2DB.normalizeId(id);
931
1154
  const hc = await this.getHistoryCollection(collectionName);
932
1155
  const row = await hc.findOne({ _uuid: id, _v: version });
933
1156
  if (!row) {