@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.
- package/README.md +57 -7
- package/{dist/data.d.ts → data.d.ts} +3 -1
- package/{dist/data.js → data.js} +2 -0
- package/{dist/db.d.ts → db.d.ts} +19 -6
- package/{dist/db.js → db.js} +297 -74
- package/package.json +13 -35
- package/dist/LICENSE +0 -674
- package/dist/README.md +0 -297
- package/dist/package.json +0 -32
package/{dist/db.js → db.js}
RENAMED
|
@@ -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 {
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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}
|
|
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}
|
|
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
|
-
|
|
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:
|
|
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
|
-
...
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
362
|
-
const newUuid =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
...
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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) {
|