@frogfish/k2db 2.0.7 → 3.0.2

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/db.js DELETED
@@ -1,1063 +0,0 @@
1
- // src/db.ts
2
- import { K2Error, ServiceError } from "@frogfish/k2error"; // Keep the existing error structure
3
- import { MongoClient, } from "mongodb";
4
- import { randomBytes } from "crypto";
5
- import debugLib from "debug";
6
- import { z } from "zod";
7
- const debug = debugLib("k2:db");
8
- // Crockford Base32 alphabet (no I, L, O, U)
9
- const CROCKFORD32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
10
- /**
11
- * Generates a UUIDv7 (time-ordered) and encodes it as Crockford Base32 with hyphens.
12
- * Format: 26 base32 chars grouped as 8-4-4-4-6 (total 26)
13
- */
14
- function uuidv7Base32Hyphenated() {
15
- // 1) Build UUIDv7 bytes
16
- // Layout per RFC: time_low(32) | time_mid(16) | time_hi_and_version(16) | clock_seq(16) | node(48)
17
- // Encode 60-bit ms timestamp across time_* fields, version 7, RFC4122 variant in clock_seq_hi.
18
- const ts = BigInt(Date.now()); // milliseconds
19
- const timeLow = Number((ts >> 28n) & 0xffffffffn);
20
- const timeMid = Number((ts >> 12n) & 0xffffn);
21
- const timeHi = Number(ts & 0xfffn); // lower 12 bits
22
- const bytes = new Uint8Array(16);
23
- // time_low (big-endian)
24
- bytes[0] = (timeLow >>> 24) & 0xff;
25
- bytes[1] = (timeLow >>> 16) & 0xff;
26
- bytes[2] = (timeLow >>> 8) & 0xff;
27
- bytes[3] = timeLow & 0xff;
28
- // time_mid (big-endian)
29
- bytes[4] = (timeMid >>> 8) & 0xff;
30
- bytes[5] = timeMid & 0xff;
31
- // time_high_and_version: version 7 in high nibble + top 4 bits of timeHi
32
- bytes[6] = 0x70 | ((timeHi >>> 8) & 0x0f); // 0x7- version
33
- bytes[7] = timeHi & 0xff;
34
- // clock_seq + node: 8 random bytes; set RFC4122 variant (10xxxxxx)
35
- const rnd = randomBytes(8);
36
- bytes.set(rnd, 8);
37
- bytes[8] = (bytes[8] & 0x3f) | 0x80; // set variant 10xxxxxx
38
- // 2) Encode as Crockford Base32 (26 chars). 128 bits -> 26*5 bits (pad 2 high bits)
39
- let value = 0n;
40
- for (let i = 0; i < 16; i++) {
41
- value = (value << 8n) | BigInt(bytes[i]);
42
- }
43
- value <<= 2n; // pad to 130 bits so we can take 26 groups cleanly
44
- let encoded = "";
45
- for (let i = 25; i >= 0; i--) {
46
- const idx = Number((value >> BigInt(i * 5)) & 0x1fn);
47
- encoded += CROCKFORD32[idx];
48
- }
49
- // 3) Insert hyphens in groups: 8-4-4-4-6
50
- return (encoded.slice(0, 8) +
51
- "-" +
52
- encoded.slice(8, 12) +
53
- "-" +
54
- encoded.slice(12, 16) +
55
- "-" +
56
- encoded.slice(16, 20) +
57
- "-" +
58
- encoded.slice(20));
59
- }
60
- export class K2DB {
61
- conf;
62
- db;
63
- connection;
64
- schemas = new Map();
65
- constructor(conf) {
66
- this.conf = conf;
67
- }
68
- /**
69
- * Initializes the MongoDB connection.
70
- */
71
- async init() {
72
- // Build URI and options
73
- const { uri, options } = this.buildMongoUri();
74
- const dbName = this.conf.name;
75
- // Mask sensitive information in logs
76
- const safeConnectUrl = uri.replace(/\/\/.*?:.*?@/, "//*****:*****@");
77
- debug(`Connecting to MongoDB: ${safeConnectUrl}`);
78
- try {
79
- // 8. Establish MongoDB connection
80
- this.connection = await MongoClient.connect(uri, options);
81
- this.db = this.connection.db(dbName);
82
- debug("Successfully connected to MongoDB");
83
- }
84
- catch (err) {
85
- // 9. Handle connection error
86
- throw new K2Error(ServiceError.SYSTEM_ERROR, `Failed to connect to MongoDB: ${err.message}`, "sys_mdb_init", this.normalizeError(err));
87
- }
88
- }
89
- /**
90
- * Build a robust MongoDB URI based on config (supports SRV and standard).
91
- */
92
- buildMongoUri() {
93
- if (!this.conf.hosts || this.conf.hosts.length === 0) {
94
- throw new K2Error(ServiceError.CONFIGURATION_ERROR, "No valid hosts provided in configuration", "sys_mdb_no_hosts");
95
- }
96
- const auth = this.conf.user && this.conf.password
97
- ? `${encodeURIComponent(this.conf.user)}:${encodeURIComponent(this.conf.password)}@`
98
- : "";
99
- const singleNoPort = this.conf.hosts.length === 1 && !this.conf.hosts[0].port;
100
- const useSrv = singleNoPort;
101
- const dbName = this.conf.name;
102
- let uri;
103
- if (useSrv) {
104
- const host = this.conf.hosts[0].host;
105
- uri = `mongodb+srv://${auth}${host}/${dbName}?retryWrites=true&w=majority`;
106
- }
107
- else {
108
- const hostList = this.conf.hosts
109
- .map((h) => `${h.host}:${h.port || 27017}`)
110
- .join(",");
111
- const params = ["retryWrites=true", "w=majority"];
112
- if (this.conf.replicaset)
113
- params.push(`replicaSet=${this.conf.replicaset}`);
114
- uri = `mongodb://${auth}${hostList}/${dbName}?${params.join("&")}`;
115
- }
116
- const options = {
117
- connectTimeoutMS: 2000,
118
- serverSelectionTimeoutMS: 2000,
119
- };
120
- return { uri, options };
121
- }
122
- /** Load DatabaseConfig from environment variables. */
123
- static fromEnv(prefix = "K2DB_") {
124
- const get = (k) => globalThis.process?.env?.[`${prefix}${k}`];
125
- const name = get("NAME");
126
- const hostsEnv = get("HOSTS");
127
- if (!name || !hostsEnv) {
128
- throw new Error("K2DB_NAME and K2DB_HOSTS are required in environment");
129
- }
130
- const hosts = hostsEnv.split(",").map((h) => {
131
- const [host, port] = h.trim().split(":");
132
- return { host, port: port ? parseInt(port, 10) : undefined };
133
- });
134
- const conf = {
135
- name,
136
- hosts,
137
- user: get("USER"),
138
- password: get("PASSWORD"),
139
- replicaset: get("REPLICASET"),
140
- };
141
- const slow = get("SLOW_MS");
142
- if (slow)
143
- conf.slowQueryMs = parseInt(slow, 10);
144
- return conf;
145
- }
146
- /**
147
- * Retrieves a collection from the database.
148
- * @param collectionName - Name of the collection.
149
- */
150
- async getCollection(collectionName) {
151
- try {
152
- this.validateCollectionName(collectionName); // Validate the collection name
153
- const collection = this.db.collection(collectionName);
154
- return collection;
155
- }
156
- catch (err) {
157
- // If the error is already an K2Error, rethrow it
158
- if (err instanceof K2Error) {
159
- throw err;
160
- }
161
- throw new K2Error(ServiceError.SYSTEM_ERROR, `Error getting collection: ${collectionName}`, "sys_mdb_gc", this.normalizeError(err));
162
- }
163
- }
164
- async get(collectionName, uuid) {
165
- const id = K2DB.normalizeId(uuid);
166
- const res = await this.findOne(collectionName, {
167
- _uuid: id,
168
- _deleted: { $ne: true },
169
- });
170
- if (!res) {
171
- throw new K2Error(ServiceError.NOT_FOUND, "Document not found", "sys_mdb_get_not_found");
172
- }
173
- return res;
174
- }
175
- /**
176
- * Retrieves a single document by UUID.
177
- * @param collectionName - Name of the collection.
178
- * @param uuid - UUID of the document.
179
- * @param objectTypeName - Optional object type name.
180
- * @param fields - Optional array of fields to include.
181
- */
182
- async findOne(collectionName, criteria, fields) {
183
- const collection = await this.getCollection(collectionName);
184
- const projection = {};
185
- // Exclude soft-deleted documents by default unless caller specifies otherwise
186
- const normalizedCriteria = K2DB.normalizeCriteriaIds(criteria || {});
187
- const query = {
188
- ...normalizedCriteria,
189
- ...(criteria && Object.prototype.hasOwnProperty.call(criteria, "_deleted")
190
- ? {}
191
- : { _deleted: { $ne: true } }),
192
- };
193
- if (fields && fields.length > 0) {
194
- fields.forEach((field) => {
195
- projection[field] = 1;
196
- });
197
- }
198
- try {
199
- const item = await this.runTimed("findOne", { collectionName, query, projection }, async () => await collection.findOne(query, { projection }));
200
- if (item) {
201
- const { _id, ...rest } = item;
202
- return rest;
203
- }
204
- return null;
205
- }
206
- catch (err) {
207
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Error finding document", "sys_mdb_fo", this.normalizeError(err));
208
- }
209
- }
210
- /**
211
- * Finds documents based on parameters with pagination support.
212
- * @param collectionName - Name of the collection.
213
- * @param filter - Criteria to filter the documents.
214
- * @param params - Optional search parameters (for sorting, including/excluding fields).
215
- * @param skip - Number of documents to skip (for pagination).
216
- * @param limit - Maximum number of documents to return.
217
- */
218
- async find(collectionName, filter, params = {}, skip = 0, limit = 100) {
219
- const collection = await this.getCollection(collectionName);
220
- // Ensure filter is valid, defaulting to an empty object
221
- let criteria = { ...(filter || {}) };
222
- criteria = K2DB.normalizeCriteriaIds(criteria);
223
- // Handle the _deleted field if params specify not to include deleted documents
224
- if (!params?.includeDeleted && !Object.prototype.hasOwnProperty.call(criteria, "_deleted")) {
225
- if (params?.deleted === true) {
226
- criteria._deleted = true; // Explicitly search for deleted documents
227
- }
228
- else {
229
- criteria._deleted = { $ne: true }; // Exclude deleted by default
230
- }
231
- }
232
- // Build projection (fields to include or exclude)
233
- let projection;
234
- if (typeof params.filter === "string" && params.filter === "all") {
235
- projection = {}; // Include all fields
236
- }
237
- else if (Array.isArray(params.filter)) {
238
- projection = {};
239
- params.filter.forEach((field) => {
240
- projection[field] = 1; // Only include the specified fields
241
- });
242
- projection._id = 0; // Hide _id when using include list
243
- }
244
- else if (Array.isArray(params.exclude)) {
245
- projection = { _id: 0 }; // Start by hiding _id
246
- params.exclude.forEach((field) => {
247
- projection[field] = 0; // Exclude the specified fields
248
- });
249
- }
250
- else {
251
- projection = { _id: 0 }; // Default: hide _id only
252
- }
253
- // Build sorting options
254
- let sort = undefined;
255
- if (params.order) {
256
- sort = {};
257
- for (const [key, value] of Object.entries(params.order)) {
258
- sort[key] = value === "asc" ? 1 : -1;
259
- }
260
- }
261
- try {
262
- let cursor = collection.find(criteria, { projection });
263
- // Apply pagination
264
- cursor = cursor.skip(skip).limit(limit);
265
- if (sort) {
266
- cursor = cursor.sort(sort);
267
- }
268
- const data = await this.runTimed("find", { collectionName, criteria, projection, sort, skip, limit }, async () => await cursor.toArray());
269
- // Remove _id safely from each document
270
- const result = data.map((doc) => {
271
- const { _id, ...rest } = doc;
272
- return rest;
273
- });
274
- return result;
275
- }
276
- catch (err) {
277
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Error executing find query", "sys_mdb_find_error", this.normalizeError(err));
278
- }
279
- }
280
- /**
281
- * Aggregates documents based on criteria with pagination support.
282
- * @param collectionName - Name of the collection.
283
- * @param criteria - Aggregation pipeline criteria.
284
- * @param skip - Number of documents to skip (for pagination).
285
- * @param limit - Maximum number of documents to return.
286
- */
287
- async aggregate(collectionName, criteria, skip = 0, limit = 100) {
288
- if (criteria.length === 0) {
289
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Aggregation criteria cannot be empty", "sys_mdb_ag_empty");
290
- }
291
- // Enforce soft-delete behavior: never return documents marked as deleted
292
- criteria = K2DB.enforceNoDeletedInPipeline(criteria);
293
- // Add pagination stages to the aggregation pipeline
294
- if (skip > 0) {
295
- criteria.push({ $skip: skip });
296
- }
297
- if (limit > 0) {
298
- criteria.push({ $limit: limit });
299
- }
300
- debug(`Aggregating with criteria: ${JSON.stringify(criteria, null, 2)}`);
301
- const collection = await this.getCollection(collectionName);
302
- // Sanitize criteria
303
- const sanitizedCriteria = criteria.map((stage) => {
304
- if (stage.$match) {
305
- return K2DB.sanitiseCriteria(stage);
306
- }
307
- return stage;
308
- });
309
- try {
310
- const data = await this.runTimed("aggregate", { collectionName, pipeline: sanitizedCriteria }, async () => await collection.aggregate(sanitizedCriteria).toArray());
311
- // Enforce BaseDocument type on each document
312
- return data.map((doc) => doc);
313
- }
314
- catch (err) {
315
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Aggregation failed", "sys_mdb_ag", this.normalizeError(err));
316
- }
317
- }
318
- /**
319
- * Ensures an aggregation pipeline excludes soft-deleted documents for the root
320
- * collection and any joined collections ($lookup, $unionWith, $graphLookup, $facet).
321
- */
322
- static enforceNoDeletedInPipeline(pipeline) {
323
- const cloned = Array.isArray(pipeline) ? pipeline.map((s) => ({ ...s })) : [];
324
- // Insert a $match to exclude deleted near the start, but after any
325
- // first-stage-only operators like $search, $geoNear, $vectorSearch.
326
- const reservedFirst = ["$search", "$geoNear", "$vectorSearch"];
327
- let insertIdx = 0;
328
- while (insertIdx < cloned.length &&
329
- typeof cloned[insertIdx] === "object" &&
330
- cloned[insertIdx] !== null &&
331
- Object.keys(cloned[insertIdx]).length === 1 &&
332
- reservedFirst.includes(Object.keys(cloned[insertIdx])[0])) {
333
- insertIdx++;
334
- }
335
- const nonDeletedMatch = { $match: { _deleted: { $ne: true } } };
336
- cloned.splice(insertIdx, 0, nonDeletedMatch);
337
- // Walk stages and enforce inside nested pipelines
338
- const mapStage = (stage) => {
339
- if (!stage || typeof stage !== "object")
340
- return stage;
341
- if (stage.$lookup) {
342
- const lu = { ...stage.$lookup };
343
- if (Array.isArray(lu.pipeline)) {
344
- // Ensure the foreign pipeline excludes deleted
345
- lu.pipeline = K2DB.enforceNoDeletedInPipeline(lu.pipeline);
346
- }
347
- else if (lu.localField && lu.foreignField) {
348
- // Convert simple lookup to pipeline lookup to filter _deleted
349
- const localVar = "__lk";
350
- lu.let = { [localVar]: `$${lu.localField}` };
351
- lu.pipeline = [
352
- {
353
- $match: {
354
- $expr: {
355
- $and: [
356
- { $eq: ["$" + lu.foreignField, "$$" + localVar] },
357
- { $ne: ["$_deleted", true] },
358
- ],
359
- },
360
- },
361
- },
362
- ];
363
- delete lu.localField;
364
- delete lu.foreignField;
365
- }
366
- return { $lookup: lu };
367
- }
368
- if (stage.$unionWith) {
369
- const uw = stage.$unionWith;
370
- if (typeof uw === "string") {
371
- return {
372
- $unionWith: {
373
- coll: uw,
374
- pipeline: [{ $match: { _deleted: { $ne: true } } }],
375
- },
376
- };
377
- }
378
- else if (uw && typeof uw === "object") {
379
- const uwc = { ...uw };
380
- uwc.pipeline = K2DB.enforceNoDeletedInPipeline(uwc.pipeline || []);
381
- return { $unionWith: uwc };
382
- }
383
- }
384
- if (stage.$graphLookup) {
385
- const gl = { ...stage.$graphLookup };
386
- const existing = gl.restrictSearchWithMatch || {};
387
- gl.restrictSearchWithMatch = { ...existing, _deleted: { $ne: true } };
388
- return { $graphLookup: gl };
389
- }
390
- if (stage.$facet) {
391
- const facets = { ...stage.$facet };
392
- for (const key of Object.keys(facets)) {
393
- facets[key] = K2DB.enforceNoDeletedInPipeline(facets[key] || []);
394
- }
395
- return { $facet: facets };
396
- }
397
- return stage;
398
- };
399
- return cloned.map(mapStage);
400
- }
401
- /**
402
- * Creates a new document in the collection.
403
- * @param collectionName - Name of the collection.
404
- * @param owner - Owner of the document.
405
- * @param data - Data to insert.
406
- */
407
- async create(collectionName, owner, data) {
408
- if (!collectionName || !owner || !data) {
409
- throw new K2Error(ServiceError.BAD_REQUEST, "Invalid method usage, parameters not defined", "sys_mdb_crv1");
410
- }
411
- if (typeof owner !== "string") {
412
- throw new K2Error(ServiceError.BAD_REQUEST, "Owner must be of a string type", "sys_mdb_crv2");
413
- }
414
- const collection = await this.getCollection(collectionName);
415
- const timestamp = Date.now();
416
- // Generate a new UUIDv7 encoded as Crockford Base32 with hyphens
417
- const newUuid = uuidv7Base32Hyphenated();
418
- // Remove reserved fields from user data, then validate/transform via schema if present
419
- const safeData = K2DB.stripReservedFields(data);
420
- const validated = this.applySchema(collectionName, safeData, /*partial*/ false);
421
- // Spread validated data first, then set internal fields to prevent overwriting
422
- const document = {
423
- ...validated,
424
- _created: timestamp,
425
- _updated: timestamp,
426
- _owner: owner,
427
- _uuid: newUuid,
428
- };
429
- try {
430
- const result = await this.runTimed("insertOne", { collectionName }, async () => await collection.insertOne(document));
431
- return { id: document._uuid };
432
- }
433
- catch (err) {
434
- // Use appropriate error typing
435
- // Check if the error is a duplicate key error
436
- if (err.code === 11000 && err.keyPattern && err.keyPattern._uuid) {
437
- throw new K2Error(ServiceError.ALREADY_EXISTS, `A document with _uuid ${document._uuid} already exists.`, "sys_mdb_crv3");
438
- }
439
- // Log the error details for debugging
440
- debug(`Was trying to insert into collection ${collectionName}, data: ${JSON.stringify(document)}`);
441
- debug(err);
442
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Error saving object to database", "sys_mdb_sav", this.normalizeError(err));
443
- }
444
- }
445
- /**
446
- * Updates multiple documents based on criteria.
447
- * Can either replace the documents or patch them.
448
- * @param collectionName - Name of the collection.
449
- * @param criteria - Update criteria.
450
- * @param values - Values to update or replace with.
451
- */
452
- async updateAll(collectionName, criteria, values) {
453
- this.validateCollectionName(collectionName);
454
- const collection = await this.getCollection(collectionName);
455
- debug(`Updating ${collectionName} with criteria: ${JSON.stringify(criteria)}`);
456
- // Preserve intent to set _deleted during internal soft-delete operations.
457
- // stripReservedFields removes underscore-prefixed keys (by design) to protect
458
- // internal fields from user updates. However, deleteAll() legitimately passes
459
- // {_deleted: true}. Capture and restore it here.
460
- const deletedFlag = Object.prototype.hasOwnProperty.call(values, "_deleted")
461
- ? values._deleted
462
- : undefined;
463
- values = K2DB.stripReservedFields(values);
464
- values = this.applySchema(collectionName, values, /*partial*/ true);
465
- values._updated = Date.now();
466
- if (deletedFlag !== undefined) {
467
- values._deleted = deletedFlag;
468
- }
469
- criteria = K2DB.normalizeCriteriaIds(criteria || {});
470
- criteria = {
471
- ...criteria,
472
- _deleted: { $ne: true },
473
- };
474
- try {
475
- const res = await this.runTimed("updateMany", { collectionName, criteria, values }, async () => await collection.updateMany(criteria, { $set: values }));
476
- return {
477
- updated: res.modifiedCount,
478
- };
479
- }
480
- catch (err) {
481
- throw new K2Error(ServiceError.SYSTEM_ERROR, `Error updating ${collectionName}`, "sys_mdb_update1", this.normalizeError(err));
482
- }
483
- }
484
- /**
485
- * Updates a single document by UUID.
486
- * Can either replace the document or patch it.
487
- * @param collectionName - Name of the collection.
488
- * @param id - UUID string to identify the document.
489
- * @param data - Data to update or replace with.
490
- * @param replace - If true, replaces the entire document (PUT), otherwise patches (PATCH).
491
- */
492
- async update(collectionName, id, data, replace = false) {
493
- id = K2DB.normalizeId(id);
494
- this.validateCollectionName(collectionName);
495
- const collection = await this.getCollection(collectionName);
496
- data = K2DB.stripReservedFields(data);
497
- data = this.applySchema(collectionName, data, /*partial*/ !replace);
498
- data._updated = Date.now(); // Set the _updated timestamp
499
- try {
500
- let res;
501
- // If replacing the document, first get the original document
502
- if (replace) {
503
- // Get the original document to preserve fields starting with underscore
504
- const originalDoc = await this.get(collectionName, id);
505
- // Override all fields starting with underscore from the original document
506
- const fieldsToPreserve = Object.keys(originalDoc).reduce((acc, key) => {
507
- if (key.startsWith("_")) {
508
- acc[key] = originalDoc[key];
509
- }
510
- return acc;
511
- }, {});
512
- // Merge the preserved fields into the data
513
- data = { ...data, ...fieldsToPreserve };
514
- data._updated = Date.now();
515
- // Now replace the document with the merged data
516
- res = await this.runTimed("replaceOne", { collectionName, _uuid: id }, async () => await collection.replaceOne({ _uuid: id, _deleted: { $ne: true } }, data));
517
- }
518
- else {
519
- // If patching, just update specific fields using $set
520
- res = await this.runTimed("updateOne", { collectionName, _uuid: id }, async () => await collection.updateOne({ _uuid: id, _deleted: { $ne: true } }, { $set: data }));
521
- }
522
- // Use matchedCount to determine existence; modifiedCount indicates actual change
523
- if (res.matchedCount === 0) {
524
- throw new K2Error(ServiceError.NOT_FOUND, `Object in ${collectionName} with UUID ${id} not found`, "sys_mdb_update_not_found");
525
- }
526
- return { updated: res.modifiedCount };
527
- }
528
- catch (err) {
529
- if (err instanceof K2Error) {
530
- throw err;
531
- }
532
- // Catch any other unhandled errors and throw a system error
533
- throw new K2Error(ServiceError.SYSTEM_ERROR, `Error updating ${collectionName}`, "sys_mdb_update_error", this.normalizeError(err));
534
- }
535
- }
536
- /**
537
- * Removes (soft deletes) multiple documents based on criteria.
538
- * @param collectionName - Name of the collection.
539
- * @param criteria - Removal criteria.
540
- */
541
- async deleteAll(collectionName, criteria) {
542
- this.validateCollectionName(collectionName);
543
- try {
544
- let result = await this.updateAll(collectionName, criteria, {
545
- _deleted: true,
546
- });
547
- return { deleted: result.updated };
548
- }
549
- catch (err) {
550
- throw new K2Error(ServiceError.SYSTEM_ERROR, `Error updating ${collectionName}`, "sys_mdb_deleteall_update", this.normalizeError(err));
551
- }
552
- }
553
- /**
554
- * Removes (soft deletes) a single document by UUID.
555
- * @param collectionName - Name of the collection.
556
- * @param id - UUID of the document.
557
- */
558
- async delete(collectionName, id) {
559
- id = K2DB.normalizeId(id);
560
- try {
561
- // Call deleteAll to soft delete the document by UUID
562
- const result = await this.deleteAll(collectionName, { _uuid: id });
563
- // Check the result of the deleteAll operation
564
- if (result.deleted === 1) {
565
- // Successfully deleted one document
566
- return { deleted: 1 };
567
- }
568
- else if (result.deleted === 0) {
569
- // No document was found to delete
570
- throw new K2Error(ServiceError.NOT_FOUND, "Document not found", "sys_mdb_remove_not_found");
571
- }
572
- else {
573
- // More than one document was deleted, which is unexpected
574
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Multiple documents deleted when only one was expected", "sys_mdb_remove_multiple_deleted");
575
- }
576
- }
577
- catch (err) {
578
- // Preserve existing K2Error classifications (e.g., NOT_FOUND)
579
- if (err instanceof K2Error) {
580
- throw err;
581
- }
582
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Error removing object from collection", "sys_mdb_remove_upd", this.normalizeError(err));
583
- }
584
- }
585
- /**
586
- * Permanently deletes a document that has been soft-deleted.
587
- * @param collectionName - Name of the collection.
588
- * @param id - UUID of the document.
589
- */
590
- async purge(collectionName, id) {
591
- id = K2DB.normalizeId(id);
592
- const collection = await this.getCollection(collectionName);
593
- try {
594
- const item = await this.runTimed("findOne", { collectionName, _uuid: id, _deleted: true }, async () => await collection.findOne({
595
- _uuid: id,
596
- _deleted: true,
597
- }));
598
- if (!item) {
599
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Cannot purge item that is not deleted", "sys_mdb_gcol_pg2");
600
- }
601
- await this.runTimed("deleteOne", { collectionName, _uuid: id }, async () => await collection.deleteOne({ _uuid: id }));
602
- return { id };
603
- }
604
- catch (err) {
605
- if (err instanceof K2Error) {
606
- throw err;
607
- }
608
- throw new K2Error(ServiceError.SYSTEM_ERROR, `Error purging item with id: ${id}`, "sys_mdb_pg", this.normalizeError(err));
609
- }
610
- }
611
- /**
612
- * Permanently deletes all documents that are soft-deleted and whose _updated
613
- * timestamp is older than the provided threshold (in milliseconds ago).
614
- * @param collectionName - Name of the collection.
615
- * @param olderThanMs - Age threshold in milliseconds; documents with
616
- * `_updated <= (Date.now() - olderThanMs)` will be purged.
617
- */
618
- async purgeDeletedOlderThan(collectionName, olderThanMs) {
619
- this.validateCollectionName(collectionName);
620
- if (typeof olderThanMs !== 'number' || !isFinite(olderThanMs) || olderThanMs < 0) {
621
- throw new K2Error(ServiceError.BAD_REQUEST, 'olderThanMs must be a non-negative number', 'sys_mdb_purge_older_invalid');
622
- }
623
- const collection = await this.getCollection(collectionName);
624
- const cutoff = Date.now() - olderThanMs;
625
- try {
626
- const res = await this.runTimed('deleteMany', { collectionName, olderThanMs, cutoff }, async () => await collection.deleteMany({
627
- _deleted: true,
628
- _updated: { $lte: cutoff },
629
- }));
630
- return { purged: res.deletedCount ?? 0 };
631
- }
632
- catch (err) {
633
- throw new K2Error(ServiceError.SYSTEM_ERROR, 'Error purging deleted items by age', 'sys_mdb_purge_older', this.normalizeError(err));
634
- }
635
- }
636
- /**
637
- * Restores a soft-deleted document.
638
- * @param collectionName - Name of the collection.
639
- * @param criteria - Criteria to identify the document.
640
- */
641
- async restore(collectionName, criteria) {
642
- const collection = await this.getCollection(collectionName);
643
- const crit = K2DB.normalizeCriteriaIds(criteria || {});
644
- const query = { ...crit, _deleted: true };
645
- try {
646
- const res = await this.runTimed("updateMany", { collectionName, query }, async () => await collection.updateMany(query, {
647
- // Restoring is a data change: flip _deleted and bump _updated
648
- $set: { _deleted: false, _updated: Date.now() },
649
- }));
650
- return { status: "restored", modified: res.modifiedCount };
651
- }
652
- catch (err) {
653
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Error restoring a deleted item", "sys_mdb_pres", this.normalizeError(err));
654
- }
655
- }
656
- /**
657
- * Counts documents based on criteria.
658
- * @param collectionName - Name of the collection.
659
- * @param criteria - Counting criteria.
660
- */
661
- async count(collectionName, criteria) {
662
- const collection = await this.getCollection(collectionName);
663
- try {
664
- const norm = K2DB.normalizeCriteriaIds(criteria || {});
665
- const query = {
666
- ...norm,
667
- ...(criteria && Object.prototype.hasOwnProperty.call(criteria, "_deleted")
668
- ? {}
669
- : { _deleted: { $ne: true } }),
670
- };
671
- const cnt = await this.runTimed("countDocuments", { collectionName, query }, async () => await collection.countDocuments(query));
672
- return { count: cnt };
673
- }
674
- catch (err) {
675
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Error counting objects with given criteria", "sys_mdb_cn", this.normalizeError(err));
676
- }
677
- }
678
- /**
679
- * Drops an entire collection.
680
- * @param collectionName - Name of the collection.
681
- */
682
- async drop(collectionName) {
683
- const collection = await this.getCollection(collectionName);
684
- try {
685
- await collection.drop();
686
- return { status: "ok" };
687
- }
688
- catch (err) {
689
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Error dropping collection", "sys_mdb_drop", this.normalizeError(err));
690
- }
691
- }
692
- /**
693
- * Sanitizes aggregation criteria.
694
- * @param criteria - Aggregation stage criteria.
695
- */
696
- static sanitiseCriteria(criteria) {
697
- if (criteria.$match) {
698
- // Normalize any _uuid values in the match object to uppercase
699
- criteria.$match = K2DB.normalizeCriteriaIds(criteria.$match);
700
- for (const key of Object.keys(criteria.$match)) {
701
- if (typeof criteria.$match[key] !== "string") {
702
- criteria.$match[key] = K2DB.sanitiseCriteria({
703
- [key]: criteria.$match[key],
704
- })[key];
705
- }
706
- else {
707
- if (key === "$exists") {
708
- criteria.$match[key] = criteria.$match[key] === "true";
709
- }
710
- }
711
- }
712
- }
713
- return criteria;
714
- }
715
- /** Recursively uppercases any values for fields named `_uuid` within a query object. */
716
- static normalizeCriteriaIds(obj) {
717
- if (!obj || typeof obj !== "object")
718
- return obj;
719
- if (Array.isArray(obj))
720
- return obj.map((v) => K2DB.normalizeCriteriaIds(v));
721
- const out = Array.isArray(obj) ? [] : { ...obj };
722
- for (const [k, v] of Object.entries(obj)) {
723
- if (k === "_uuid") {
724
- out[k] = K2DB.normalizeUuidField(v);
725
- }
726
- else if (v && typeof v === "object") {
727
- out[k] = K2DB.normalizeCriteriaIds(v);
728
- }
729
- else if (Array.isArray(v)) {
730
- out[k] = v.map((x) => K2DB.normalizeCriteriaIds(x));
731
- }
732
- else {
733
- out[k] = v;
734
- }
735
- }
736
- return out;
737
- }
738
- /** Uppercase helper for `_uuid` field supporting operators like $in/$nin/$eq/$ne and arrays. */
739
- static normalizeUuidField(val) {
740
- if (typeof val === "string")
741
- return val.toUpperCase();
742
- if (Array.isArray(val))
743
- return val.map((x) => (typeof x === "string" ? x.toUpperCase() : x));
744
- if (val && typeof val === "object") {
745
- const out = { ...val };
746
- for (const op of ["$in", "$nin", "$eq", "$ne", "$all"]) {
747
- if (op in out)
748
- out[op] = K2DB.normalizeUuidField(out[op]);
749
- }
750
- return out;
751
- }
752
- return val;
753
- }
754
- /** Strip any user-provided fields that start with '_' (reserved). */
755
- static stripReservedFields(obj) {
756
- const out = {};
757
- for (const [k, v] of Object.entries(obj || {})) {
758
- if (!k.startsWith("_"))
759
- out[k] = v;
760
- }
761
- return out;
762
- }
763
- /** True if string matches K2 ID format (Crockford Base32, 8-4-4-4-6, uppercase). */
764
- static isK2ID(id) {
765
- if (typeof id !== "string")
766
- return false;
767
- const s = id.trim().toUpperCase();
768
- const CROCK_RE = /^[0-9A-HJKMNPQRSTVWXYZ]{8}-[0-9A-HJKMNPQRSTVWXYZ]{4}-[0-9A-HJKMNPQRSTVWXYZ]{4}-[0-9A-HJKMNPQRSTVWXYZ]{4}-[0-9A-HJKMNPQRSTVWXYZ]{6}$/;
769
- return CROCK_RE.test(s);
770
- }
771
- /** Uppercase incoming IDs for case-insensitive lookups. */
772
- static normalizeId(id) {
773
- return id.toUpperCase();
774
- }
775
- /**
776
- * Run an async DB operation with timing, slow logging, and hooks.
777
- */
778
- async runTimed(op, details, fn) {
779
- try {
780
- this.conf.hooks?.beforeQuery?.(op, details);
781
- }
782
- catch { }
783
- const start = Date.now();
784
- try {
785
- const res = await fn();
786
- const dur = Date.now() - start;
787
- const slow = this.conf.slowQueryMs ?? 200;
788
- if (dur > slow) {
789
- debug(`[SLOW ${op}] ${dur}ms ${JSON.stringify(details)}`);
790
- }
791
- try {
792
- this.conf.hooks?.afterQuery?.(op, { ...details, ok: true }, dur);
793
- }
794
- catch { }
795
- return res;
796
- }
797
- catch (e) {
798
- const dur = Date.now() - start;
799
- try {
800
- this.conf.hooks?.afterQuery?.(op, { ...details, ok: false, error: e instanceof Error ? e.message : String(e) }, dur);
801
- }
802
- catch { }
803
- throw e;
804
- }
805
- }
806
- /**
807
- * Ensure commonly needed indexes exist.
808
- */
809
- async ensureIndexes(collectionName, opts = {}) {
810
- const { uuidUnique = false, uuidPartialUnique = true, ownerIndex = true, deletedIndex = true, } = opts;
811
- const collection = await this.getCollection(collectionName);
812
- if (uuidPartialUnique) {
813
- // Use a compound unique index to ensure at most one non-deleted document per _uuid
814
- // without relying on partialFilterExpression (which may be limited in some environments).
815
- await collection.createIndex({ _uuid: 1, _deleted: 1 }, { unique: true });
816
- }
817
- else if (uuidUnique) {
818
- await collection.createIndex({ _uuid: 1 }, { unique: true });
819
- }
820
- if (ownerIndex) {
821
- await collection.createIndex({ _owner: 1 });
822
- }
823
- if (deletedIndex) {
824
- await collection.createIndex({ _deleted: 1 });
825
- }
826
- }
827
- /**
828
- * Optional: Executes a transaction with the provided operations.
829
- * @param operations - A function that performs operations within a transaction session.
830
- */
831
- async executeTransaction(operations) {
832
- const session = this.connection.startSession();
833
- session.startTransaction();
834
- try {
835
- await operations(session);
836
- await session.commitTransaction();
837
- }
838
- catch (error) {
839
- await session.abortTransaction();
840
- throw this.normalizeError(error);
841
- }
842
- finally {
843
- session.endSession();
844
- }
845
- }
846
- /**
847
- * Optional: Creates an index on the specified collection.
848
- * @param collectionName - Name of the collection.
849
- * @param indexSpec - Specification of the index.
850
- * @param options - Optional index options.
851
- */
852
- async createIndex(collectionName, indexSpec, options) {
853
- const collection = await this.getCollection(collectionName);
854
- try {
855
- await collection.createIndex(indexSpec, options);
856
- debug(`Index created on ${collectionName}: ${JSON.stringify(indexSpec)}`);
857
- }
858
- catch (err) {
859
- throw new K2Error(ServiceError.SYSTEM_ERROR, `Error creating index on ${collectionName}`, "sys_mdb_idx", this.normalizeError(err));
860
- }
861
- }
862
- /**
863
- * Releases the MongoDB connection.
864
- */
865
- async release() {
866
- await this.connection.close();
867
- debug("MongoDB connection released");
868
- }
869
- /**
870
- * Closes the MongoDB connection.
871
- */
872
- close() {
873
- this.connection.close();
874
- }
875
- /**
876
- * Drops the entire database.
877
- */
878
- async dropDatabase() {
879
- try {
880
- await this.db.dropDatabase();
881
- debug("Database dropped successfully");
882
- }
883
- catch (err) {
884
- throw new K2Error(ServiceError.SYSTEM_ERROR, "Error dropping database", "sys_mdb_drop_db", this.normalizeError(err));
885
- }
886
- }
887
- /**
888
- * Validates the MongoDB collection name.
889
- * @param collectionName - The name of the collection to validate.
890
- * @throws {K2Error} - If the collection name is invalid.
891
- */
892
- validateCollectionName(collectionName) {
893
- // Check for null character
894
- if (collectionName.includes("\0")) {
895
- throw new K2Error(ServiceError.BAD_REQUEST, "Collection name cannot contain null characters", "sys_mdb_invalid_collection_name");
896
- }
897
- // Check if it starts with 'system.'
898
- if (collectionName.startsWith("system.")) {
899
- throw new K2Error(ServiceError.BAD_REQUEST, "Collection name cannot start with 'system.'", "sys_mdb_invalid_collection_name");
900
- }
901
- // Check for invalid characters (e.g., '$')
902
- if (collectionName.includes("$")) {
903
- throw new K2Error(ServiceError.BAD_REQUEST, "Collection name cannot contain the '$' character", "sys_mdb_invalid_collection_name");
904
- }
905
- // Additional checks can be added here as needed
906
- }
907
- /**
908
- * Optional: Checks the health of the database connection.
909
- */
910
- async isHealthy() {
911
- try {
912
- await this.db.command({ ping: 1 });
913
- return true;
914
- }
915
- catch {
916
- return false;
917
- }
918
- }
919
- /**
920
- * Utility to normalize the error type.
921
- * @param err - The caught error of type `unknown`.
922
- * @returns A normalized error of type `Error`.
923
- */
924
- normalizeError(err) {
925
- return err instanceof Error ? err : new Error(String(err));
926
- }
927
- // ===== Versioning helpers and APIs =====
928
- /** Name of the history collection for a given collection. */
929
- historyName(collectionName) {
930
- return `${collectionName}__history`;
931
- }
932
- // ===== Zod schema registry (opt-in) =====
933
- /** Register a Zod schema for a collection. */
934
- setSchema(collectionName, schema, options = {}) {
935
- const mode = options.mode ?? "strip";
936
- this.schemas.set(collectionName, { schema, mode });
937
- }
938
- /** Clear a collection's schema. */
939
- clearSchema(collectionName) {
940
- this.schemas.delete(collectionName);
941
- }
942
- /** Clear all schemas. */
943
- clearSchemas() {
944
- this.schemas.clear();
945
- }
946
- /** Apply registered schema (if any) to data. For updates, partial=true allows partial input. */
947
- applySchema(collectionName, data, partial) {
948
- const entry = this.schemas.get(collectionName);
949
- if (!entry)
950
- return data;
951
- let s = entry.schema;
952
- // If schema is an object, apply unknown key policy and partial when needed
953
- if (s instanceof z.ZodObject) {
954
- const so = s;
955
- const shaped = entry.mode === "strict" ? so.strict() : entry.mode === "passthrough" ? so.passthrough() : so.strip();
956
- s = partial ? shaped.partial() : shaped;
957
- }
958
- else {
959
- // Non-object schema: partial has no effect; leave as-is
960
- }
961
- const parsed = s.safeParse(data);
962
- if (!parsed.success) {
963
- throw new K2Error(ServiceError.BAD_REQUEST, "Validation failed", "sys_mdb_schema_validation", new Error(parsed.error.message));
964
- }
965
- return parsed.data;
966
- }
967
- /** Get the history collection. */
968
- async getHistoryCollection(collectionName) {
969
- return this.db.collection(this.historyName(collectionName));
970
- }
971
- /** Ensure indexes for history tracking. */
972
- async ensureHistoryIndexes(collectionName) {
973
- const hc = await this.getHistoryCollection(collectionName);
974
- await hc.createIndex({ _uuid: 1, _v: 1 }, { unique: true });
975
- await hc.createIndex({ _uuid: 1, _at: -1 });
976
- }
977
- /** Compute the next version number for a document. */
978
- async nextVersion(collectionName, id) {
979
- id = K2DB.normalizeId(id);
980
- const hc = await this.getHistoryCollection(collectionName);
981
- const last = await hc
982
- .find({ _uuid: id })
983
- .project({ _v: 1 })
984
- .sort({ _v: -1 })
985
- .limit(1)
986
- .toArray();
987
- return last.length ? last[0]._v + 1 : 1;
988
- }
989
- /** Save a snapshot of the current document into the history collection. */
990
- async snapshotCurrent(collectionName, current) {
991
- const hc = await this.getHistoryCollection(collectionName);
992
- const version = await this.nextVersion(collectionName, current._uuid);
993
- await hc.insertOne({
994
- _uuid: current._uuid,
995
- _v: version,
996
- _at: Date.now(),
997
- snapshot: current,
998
- });
999
- return { version };
1000
- }
1001
- /**
1002
- * Update a document and keep the previous version in a history collection.
1003
- * If maxVersions is provided, prunes oldest snapshots beyond that number.
1004
- */
1005
- async updateVersioned(collectionName, id, data, replace = false, maxVersions) {
1006
- id = K2DB.normalizeId(id);
1007
- // Get current doc (excludes deleted) and snapshot it
1008
- const current = await this.get(collectionName, id);
1009
- await this.ensureHistoryIndexes(collectionName);
1010
- const { version } = await this.snapshotCurrent(collectionName, current);
1011
- // Perform update
1012
- const res = await this.update(collectionName, id, data, replace);
1013
- // Optionally prune old versions
1014
- if (typeof maxVersions === "number" && maxVersions >= 0) {
1015
- const hc = await this.getHistoryCollection(collectionName);
1016
- const count = await hc.countDocuments({ _uuid: id });
1017
- const overflow = count - maxVersions;
1018
- if (overflow > 0) {
1019
- const olds = await hc
1020
- .find({ _uuid: id })
1021
- .project({ _v: 1 })
1022
- .sort({ _v: 1 })
1023
- .limit(overflow)
1024
- .toArray();
1025
- const vs = olds.map((o) => o._v);
1026
- if (vs.length) {
1027
- await hc.deleteMany({ _uuid: id, _v: { $in: vs } });
1028
- }
1029
- }
1030
- }
1031
- return [{ updated: res.updated, versionSaved: version }];
1032
- }
1033
- /** List versions (latest first). */
1034
- async listVersions(collectionName, id, skip = 0, limit = 20) {
1035
- id = K2DB.normalizeId(id);
1036
- const hc = await this.getHistoryCollection(collectionName);
1037
- const rows = await hc
1038
- .find({ _uuid: id })
1039
- .project({ _uuid: 1, _v: 1, _at: 1 })
1040
- .sort({ _v: -1 })
1041
- .skip(skip)
1042
- .limit(limit)
1043
- .toArray();
1044
- return rows;
1045
- }
1046
- /** Revert the current document to a specific historical version (preserves metadata). */
1047
- async revertToVersion(collectionName, id, version) {
1048
- id = K2DB.normalizeId(id);
1049
- const hc = await this.getHistoryCollection(collectionName);
1050
- const row = await hc.findOne({ _uuid: id, _v: version });
1051
- if (!row) {
1052
- throw new K2Error(ServiceError.NOT_FOUND, `Version ${version} for ${id} not found`, "sys_mdb_version_not_found");
1053
- }
1054
- const snapshot = row.snapshot;
1055
- // Only apply non-underscore fields; metadata is preserved by replace=true path
1056
- const apply = {};
1057
- for (const [k, v] of Object.entries(snapshot)) {
1058
- if (!k.startsWith("_"))
1059
- apply[k] = v;
1060
- }
1061
- return this.update(collectionName, id, apply, true);
1062
- }
1063
- }