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