@frogfish/k2db 1.0.14 → 2.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 +297 -1
- package/data.d.ts +27 -24
- package/data.js +33 -5
- package/db.d.ts +104 -28
- package/db.js +435 -148
- package/package.json +18 -11
package/db.js
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
"use strict";
|
|
2
1
|
// src/db.ts
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
};
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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();
|
|
14
13
|
constructor(conf) {
|
|
15
14
|
this.conf = conf;
|
|
16
15
|
}
|
|
@@ -18,76 +17,80 @@ class K2DB {
|
|
|
18
17
|
* Initializes the MongoDB connection.
|
|
19
18
|
*/
|
|
20
19
|
async init() {
|
|
21
|
-
//
|
|
20
|
+
// Build URI and options
|
|
21
|
+
const { uri, options } = this.buildMongoUri();
|
|
22
22
|
const dbName = this.conf.name;
|
|
23
|
-
// 2. Build the connection string with user/password if available
|
|
24
|
-
// (Change 'mongodb+srv://' back to 'mongodb://' if you're not using an SRV connection)
|
|
25
|
-
let connectUrl = "mongodb+srv://";
|
|
26
|
-
if (this.conf.user && this.conf.password) {
|
|
27
|
-
connectUrl += `${encodeURIComponent(this.conf.user)}:${encodeURIComponent(this.conf.password)}@`;
|
|
28
|
-
}
|
|
29
|
-
// 3. If no hosts, throw an error
|
|
30
|
-
if (!this.conf.hosts || this.conf.hosts.length === 0) {
|
|
31
|
-
throw new k2error_1.K2Error(k2error_1.ServiceError.CONFIGURATION_ERROR, "No valid hosts provided in configuration", "sys_mdb_no_hosts");
|
|
32
|
-
}
|
|
33
|
-
// 4. Handle single vs multiple hosts
|
|
34
|
-
// - If SRV, you typically only specify the first host (no port)
|
|
35
|
-
// - If non-SRV, you'd do: .map((host) => `${host.host}:${host.port || 27017}`).join(",")
|
|
36
|
-
//
|
|
37
|
-
// For SRV, we *usually* just do the first host:
|
|
38
|
-
connectUrl += this.conf.hosts[0].host;
|
|
39
|
-
// 5. Append the DB name
|
|
40
|
-
// If SRV with Atlas, you can also append `retryWrites=true&w=majority`, etc.
|
|
41
|
-
// If you need to pass additional params (e.g., replicaSet), you can do so below.
|
|
42
|
-
connectUrl += `/${dbName}`;
|
|
43
|
-
// 6. Append replicaset if more than one host and replicaset is defined.
|
|
44
|
-
// (Typically, SRV records handle the replicaSet automatically, so you may not need this.
|
|
45
|
-
// But if you do, keep this logic.)
|
|
46
|
-
if (this.conf.hosts.length > 1 && this.conf.replicaset) {
|
|
47
|
-
// If you already have `/${dbName}?...`, you need to add `&` instead of `?`.
|
|
48
|
-
// For simplicity, we do a conditional:
|
|
49
|
-
if (connectUrl.includes("?")) {
|
|
50
|
-
connectUrl += `&replicaSet=${this.conf.replicaset}&keepAlive=true&autoReconnect=true&socketTimeoutMS=0`;
|
|
51
|
-
}
|
|
52
|
-
else {
|
|
53
|
-
connectUrl += `?replicaSet=${this.conf.replicaset}&keepAlive=true&autoReconnect=true&socketTimeoutMS=0`;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
else {
|
|
57
|
-
// For SRV + Atlas: often we want at least `?retryWrites=true&w=majority`
|
|
58
|
-
if (!connectUrl.includes("?")) {
|
|
59
|
-
connectUrl += "?retryWrites=true&w=majority";
|
|
60
|
-
}
|
|
61
|
-
else {
|
|
62
|
-
connectUrl += "&retryWrites=true&w=majority";
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
23
|
// Mask sensitive information in logs
|
|
66
|
-
const safeConnectUrl =
|
|
24
|
+
const safeConnectUrl = uri.replace(/\/\/.*?:.*?@/, "//*****:*****@");
|
|
67
25
|
debug(`Connecting to MongoDB: ${safeConnectUrl}`);
|
|
68
|
-
// 7. Define connection options with timeouts
|
|
69
|
-
// (Keep from original code, or update as needed)
|
|
70
|
-
const options = {
|
|
71
|
-
connectTimeoutMS: 2000,
|
|
72
|
-
serverSelectionTimeoutMS: 2000,
|
|
73
|
-
// If you want the Stable API (like in your _init), do:
|
|
74
|
-
// serverApi: {
|
|
75
|
-
// version: ServerApiVersion.v1,
|
|
76
|
-
// strict: true,
|
|
77
|
-
// deprecationErrors: true,
|
|
78
|
-
// },
|
|
79
|
-
};
|
|
80
26
|
try {
|
|
81
27
|
// 8. Establish MongoDB connection
|
|
82
|
-
this.connection = await
|
|
28
|
+
this.connection = await MongoClient.connect(uri, options);
|
|
83
29
|
this.db = this.connection.db(dbName);
|
|
84
30
|
debug("Successfully connected to MongoDB");
|
|
85
31
|
}
|
|
86
32
|
catch (err) {
|
|
87
33
|
// 9. Handle connection error
|
|
88
|
-
throw new
|
|
34
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, `Failed to connect to MongoDB: ${err.message}`, "sys_mdb_init", this.normalizeError(err));
|
|
89
35
|
}
|
|
90
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
|
+
}
|
|
91
94
|
/**
|
|
92
95
|
* Retrieves a collection from the database.
|
|
93
96
|
* @param collectionName - Name of the collection.
|
|
@@ -100,10 +103,10 @@ class K2DB {
|
|
|
100
103
|
}
|
|
101
104
|
catch (err) {
|
|
102
105
|
// If the error is already an K2Error, rethrow it
|
|
103
|
-
if (err instanceof
|
|
106
|
+
if (err instanceof K2Error) {
|
|
104
107
|
throw err;
|
|
105
108
|
}
|
|
106
|
-
throw new
|
|
109
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, `Error getting collection: ${collectionName}`, "sys_mdb_gc", this.normalizeError(err));
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
112
|
async get(collectionName, uuid) {
|
|
@@ -112,7 +115,7 @@ class K2DB {
|
|
|
112
115
|
_deleted: { $ne: true },
|
|
113
116
|
});
|
|
114
117
|
if (!res) {
|
|
115
|
-
throw new
|
|
118
|
+
throw new K2Error(ServiceError.NOT_FOUND, "Document not found", "sys_mdb_get_not_found");
|
|
116
119
|
}
|
|
117
120
|
return res;
|
|
118
121
|
}
|
|
@@ -126,13 +129,20 @@ class K2DB {
|
|
|
126
129
|
async findOne(collectionName, criteria, fields) {
|
|
127
130
|
const collection = await this.getCollection(collectionName);
|
|
128
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
|
+
};
|
|
129
139
|
if (fields && fields.length > 0) {
|
|
130
140
|
fields.forEach((field) => {
|
|
131
141
|
projection[field] = 1;
|
|
132
142
|
});
|
|
133
143
|
}
|
|
134
144
|
try {
|
|
135
|
-
const item = await collection.findOne(
|
|
145
|
+
const item = await this.runTimed("findOne", { collectionName, query, projection }, async () => await collection.findOne(query, { projection }));
|
|
136
146
|
if (item) {
|
|
137
147
|
const { _id, ...rest } = item;
|
|
138
148
|
return rest;
|
|
@@ -140,7 +150,7 @@ class K2DB {
|
|
|
140
150
|
return null;
|
|
141
151
|
}
|
|
142
152
|
catch (err) {
|
|
143
|
-
throw new
|
|
153
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, "Error finding document", "sys_mdb_fo", this.normalizeError(err));
|
|
144
154
|
}
|
|
145
155
|
}
|
|
146
156
|
/**
|
|
@@ -154,32 +164,37 @@ class K2DB {
|
|
|
154
164
|
async find(collectionName, filter, params = {}, skip = 0, limit = 100) {
|
|
155
165
|
const collection = await this.getCollection(collectionName);
|
|
156
166
|
// Ensure filter is valid, defaulting to an empty object
|
|
157
|
-
const criteria = filter || {};
|
|
167
|
+
const criteria = { ...(filter || {}) };
|
|
158
168
|
// Handle the _deleted field if params specify not to include deleted documents
|
|
159
|
-
if (params.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
criteria._deleted = { $ne: true }; // Exclude deleted by default
|
|
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
|
+
}
|
|
167
176
|
}
|
|
168
177
|
// Build projection (fields to include or exclude)
|
|
169
|
-
let projection
|
|
178
|
+
let projection;
|
|
170
179
|
if (typeof params.filter === "string" && params.filter === "all") {
|
|
171
180
|
projection = {}; // Include all fields
|
|
172
181
|
}
|
|
173
182
|
else if (Array.isArray(params.filter)) {
|
|
183
|
+
projection = {};
|
|
174
184
|
params.filter.forEach((field) => {
|
|
175
185
|
projection[field] = 1; // Only include the specified fields
|
|
176
186
|
});
|
|
187
|
+
projection._id = 0; // Hide _id when using include list
|
|
177
188
|
}
|
|
178
|
-
if (Array.isArray(params.exclude)) {
|
|
189
|
+
else if (Array.isArray(params.exclude)) {
|
|
190
|
+
projection = { _id: 0 }; // Start by hiding _id
|
|
179
191
|
params.exclude.forEach((field) => {
|
|
180
192
|
projection[field] = 0; // Exclude the specified fields
|
|
181
193
|
});
|
|
182
194
|
}
|
|
195
|
+
else {
|
|
196
|
+
projection = { _id: 0 }; // Default: hide _id only
|
|
197
|
+
}
|
|
183
198
|
// Build sorting options
|
|
184
199
|
let sort = undefined;
|
|
185
200
|
if (params.order) {
|
|
@@ -195,7 +210,7 @@ class K2DB {
|
|
|
195
210
|
if (sort) {
|
|
196
211
|
cursor = cursor.sort(sort);
|
|
197
212
|
}
|
|
198
|
-
const data = await cursor.toArray();
|
|
213
|
+
const data = await this.runTimed("find", { collectionName, criteria, projection, sort, skip, limit }, async () => await cursor.toArray());
|
|
199
214
|
// Remove _id safely from each document
|
|
200
215
|
const result = data.map((doc) => {
|
|
201
216
|
const { _id, ...rest } = doc;
|
|
@@ -204,7 +219,7 @@ class K2DB {
|
|
|
204
219
|
return result;
|
|
205
220
|
}
|
|
206
221
|
catch (err) {
|
|
207
|
-
throw new
|
|
222
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, "Error executing find query", "sys_mdb_find_error", this.normalizeError(err));
|
|
208
223
|
}
|
|
209
224
|
}
|
|
210
225
|
/**
|
|
@@ -216,15 +231,10 @@ class K2DB {
|
|
|
216
231
|
*/
|
|
217
232
|
async aggregate(collectionName, criteria, skip = 0, limit = 100) {
|
|
218
233
|
if (criteria.length === 0) {
|
|
219
|
-
throw new
|
|
220
|
-
}
|
|
221
|
-
// Ensure we always exclude soft-deleted documents
|
|
222
|
-
if (criteria[0].$match) {
|
|
223
|
-
criteria[0].$match = { ...criteria[0].$match, _deleted: { $ne: true } };
|
|
224
|
-
}
|
|
225
|
-
else {
|
|
226
|
-
criteria.unshift({ $match: { _deleted: { $ne: true } } });
|
|
234
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, "Aggregation criteria cannot be empty", "sys_mdb_ag_empty");
|
|
227
235
|
}
|
|
236
|
+
// Enforce soft-delete behavior: never return documents marked as deleted
|
|
237
|
+
criteria = K2DB.enforceNoDeletedInPipeline(criteria);
|
|
228
238
|
// Add pagination stages to the aggregation pipeline
|
|
229
239
|
if (skip > 0) {
|
|
230
240
|
criteria.push({ $skip: skip });
|
|
@@ -242,14 +252,97 @@ class K2DB {
|
|
|
242
252
|
return stage;
|
|
243
253
|
});
|
|
244
254
|
try {
|
|
245
|
-
const data = await collection.aggregate(sanitizedCriteria).toArray();
|
|
255
|
+
const data = await this.runTimed("aggregate", { collectionName, pipeline: sanitizedCriteria }, async () => await collection.aggregate(sanitizedCriteria).toArray());
|
|
246
256
|
// Enforce BaseDocument type on each document
|
|
247
257
|
return data.map((doc) => doc);
|
|
248
258
|
}
|
|
249
259
|
catch (err) {
|
|
250
|
-
throw new
|
|
260
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, "Aggregation failed", "sys_mdb_ag", this.normalizeError(err));
|
|
251
261
|
}
|
|
252
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
|
+
}
|
|
253
346
|
/**
|
|
254
347
|
* Creates a new document in the collection.
|
|
255
348
|
* @param collectionName - Name of the collection.
|
|
@@ -258,37 +351,40 @@ class K2DB {
|
|
|
258
351
|
*/
|
|
259
352
|
async create(collectionName, owner, data) {
|
|
260
353
|
if (!collectionName || !owner || !data) {
|
|
261
|
-
throw new
|
|
354
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Invalid method usage, parameters not defined", "sys_mdb_crv1");
|
|
262
355
|
}
|
|
263
356
|
if (typeof owner !== "string") {
|
|
264
|
-
throw new
|
|
357
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Owner must be of a string type", "sys_mdb_crv2");
|
|
265
358
|
}
|
|
266
359
|
const collection = await this.getCollection(collectionName);
|
|
267
360
|
const timestamp = Date.now();
|
|
268
361
|
// Generate a new UUID
|
|
269
|
-
const newUuid = (
|
|
270
|
-
//
|
|
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
|
|
271
367
|
const document = {
|
|
272
|
-
...
|
|
368
|
+
...validated,
|
|
273
369
|
_created: timestamp,
|
|
274
370
|
_updated: timestamp,
|
|
275
371
|
_owner: owner,
|
|
276
372
|
_uuid: newUuid,
|
|
277
373
|
};
|
|
278
374
|
try {
|
|
279
|
-
const result = await collection.insertOne(document);
|
|
375
|
+
const result = await this.runTimed("insertOne", { collectionName }, async () => await collection.insertOne(document));
|
|
280
376
|
return { id: document._uuid };
|
|
281
377
|
}
|
|
282
378
|
catch (err) {
|
|
283
379
|
// Use appropriate error typing
|
|
284
380
|
// Check if the error is a duplicate key error
|
|
285
381
|
if (err.code === 11000 && err.keyPattern && err.keyPattern._uuid) {
|
|
286
|
-
throw new
|
|
382
|
+
throw new K2Error(ServiceError.ALREADY_EXISTS, `A document with _uuid ${document._uuid} already exists.`, "sys_mdb_crv3");
|
|
287
383
|
}
|
|
288
384
|
// Log the error details for debugging
|
|
289
385
|
debug(`Was trying to insert into collection ${collectionName}, data: ${JSON.stringify(document)}`);
|
|
290
386
|
debug(err);
|
|
291
|
-
throw new
|
|
387
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, "Error saving object to database", "sys_mdb_sav", this.normalizeError(err));
|
|
292
388
|
}
|
|
293
389
|
}
|
|
294
390
|
/**
|
|
@@ -302,19 +398,21 @@ class K2DB {
|
|
|
302
398
|
this.validateCollectionName(collectionName);
|
|
303
399
|
const collection = await this.getCollection(collectionName);
|
|
304
400
|
debug(`Updating ${collectionName} with criteria: ${JSON.stringify(criteria)}`);
|
|
401
|
+
values = K2DB.stripReservedFields(values);
|
|
402
|
+
values = this.applySchema(collectionName, values, /*partial*/ true);
|
|
305
403
|
values._updated = Date.now();
|
|
306
404
|
criteria = {
|
|
307
405
|
...criteria,
|
|
308
406
|
_deleted: { $ne: true },
|
|
309
407
|
};
|
|
310
408
|
try {
|
|
311
|
-
const res = await collection.updateMany(criteria, { $set: values });
|
|
409
|
+
const res = await this.runTimed("updateMany", { collectionName, criteria, values }, async () => await collection.updateMany(criteria, { $set: values }));
|
|
312
410
|
return {
|
|
313
411
|
updated: res.modifiedCount,
|
|
314
412
|
};
|
|
315
413
|
}
|
|
316
414
|
catch (err) {
|
|
317
|
-
throw new
|
|
415
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, `Error updating ${collectionName}`, "sys_mdb_update1", this.normalizeError(err));
|
|
318
416
|
}
|
|
319
417
|
}
|
|
320
418
|
/**
|
|
@@ -328,6 +426,8 @@ class K2DB {
|
|
|
328
426
|
async update(collectionName, id, data, replace = false) {
|
|
329
427
|
this.validateCollectionName(collectionName);
|
|
330
428
|
const collection = await this.getCollection(collectionName);
|
|
429
|
+
data = K2DB.stripReservedFields(data);
|
|
430
|
+
data = this.applySchema(collectionName, data, /*partial*/ !replace);
|
|
331
431
|
data._updated = Date.now(); // Set the _updated timestamp
|
|
332
432
|
try {
|
|
333
433
|
let res;
|
|
@@ -346,33 +446,24 @@ class K2DB {
|
|
|
346
446
|
data = { ...data, ...fieldsToPreserve };
|
|
347
447
|
data._updated = Date.now();
|
|
348
448
|
// Now replace the document with the merged data
|
|
349
|
-
res = await collection.replaceOne({ _uuid: id }, data);
|
|
449
|
+
res = await this.runTimed("replaceOne", { collectionName, _uuid: id }, async () => await collection.replaceOne({ _uuid: id, _deleted: { $ne: true } }, data));
|
|
350
450
|
}
|
|
351
451
|
else {
|
|
352
452
|
// If patching, just update specific fields using $set
|
|
353
|
-
res = await collection.updateOne({ _uuid: id }, { $set: data });
|
|
354
|
-
}
|
|
355
|
-
// Check if exactly one document was updated
|
|
356
|
-
if (res.modifiedCount === 1) {
|
|
357
|
-
return { updated: 1 };
|
|
358
|
-
}
|
|
359
|
-
// If no document was updated, throw a NOT_FOUND error
|
|
360
|
-
if (res.modifiedCount === 0) {
|
|
361
|
-
throw new k2error_1.K2Error(k2error_1.ServiceError.NOT_FOUND, `Object in ${collectionName} with UUID ${id} not found`, "sys_mdb_update_not_found");
|
|
453
|
+
res = await this.runTimed("updateOne", { collectionName, _uuid: id }, async () => await collection.updateOne({ _uuid: id, _deleted: { $ne: true } }, { $set: data }));
|
|
362
454
|
}
|
|
363
|
-
//
|
|
364
|
-
if (res.
|
|
365
|
-
throw new
|
|
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");
|
|
366
458
|
}
|
|
367
|
-
|
|
368
|
-
return { updated: 0 };
|
|
459
|
+
return { updated: res.modifiedCount };
|
|
369
460
|
}
|
|
370
461
|
catch (err) {
|
|
371
|
-
if (err instanceof
|
|
462
|
+
if (err instanceof K2Error) {
|
|
372
463
|
throw err;
|
|
373
464
|
}
|
|
374
465
|
// Catch any other unhandled errors and throw a system error
|
|
375
|
-
throw new
|
|
466
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, `Error updating ${collectionName}`, "sys_mdb_update_error", this.normalizeError(err));
|
|
376
467
|
}
|
|
377
468
|
}
|
|
378
469
|
/**
|
|
@@ -389,7 +480,7 @@ class K2DB {
|
|
|
389
480
|
return { deleted: result.updated };
|
|
390
481
|
}
|
|
391
482
|
catch (err) {
|
|
392
|
-
throw new
|
|
483
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, `Error updating ${collectionName}`, "sys_mdb_deleteall_update", this.normalizeError(err));
|
|
393
484
|
}
|
|
394
485
|
}
|
|
395
486
|
/**
|
|
@@ -408,15 +499,15 @@ class K2DB {
|
|
|
408
499
|
}
|
|
409
500
|
else if (result.deleted === 0) {
|
|
410
501
|
// No document was found to delete
|
|
411
|
-
throw new
|
|
502
|
+
throw new K2Error(ServiceError.NOT_FOUND, "Document not found", "sys_mdb_remove_not_found");
|
|
412
503
|
}
|
|
413
504
|
else {
|
|
414
505
|
// More than one document was deleted, which is unexpected
|
|
415
|
-
throw new
|
|
506
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, "Multiple documents deleted when only one was expected", "sys_mdb_remove_multiple_deleted");
|
|
416
507
|
}
|
|
417
508
|
}
|
|
418
509
|
catch (err) {
|
|
419
|
-
throw new
|
|
510
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, "Error removing object from collection", "sys_mdb_remove_upd", this.normalizeError(err));
|
|
420
511
|
}
|
|
421
512
|
}
|
|
422
513
|
/**
|
|
@@ -427,21 +518,21 @@ class K2DB {
|
|
|
427
518
|
async purge(collectionName, id) {
|
|
428
519
|
const collection = await this.getCollection(collectionName);
|
|
429
520
|
try {
|
|
430
|
-
const item = await collection.findOne({
|
|
521
|
+
const item = await this.runTimed("findOne", { collectionName, _uuid: id, _deleted: true }, async () => await collection.findOne({
|
|
431
522
|
_uuid: id,
|
|
432
523
|
_deleted: true,
|
|
433
|
-
});
|
|
524
|
+
}));
|
|
434
525
|
if (!item) {
|
|
435
|
-
throw new
|
|
526
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, "Cannot purge item that is not deleted", "sys_mdb_gcol_pg2");
|
|
436
527
|
}
|
|
437
|
-
await collection.
|
|
528
|
+
await this.runTimed("deleteOne", { collectionName, _uuid: id }, async () => await collection.deleteOne({ _uuid: id }));
|
|
438
529
|
return { id };
|
|
439
530
|
}
|
|
440
531
|
catch (err) {
|
|
441
|
-
if (err instanceof
|
|
532
|
+
if (err instanceof K2Error) {
|
|
442
533
|
throw err;
|
|
443
534
|
}
|
|
444
|
-
throw new
|
|
535
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, `Error purging item with id: ${id}`, "sys_mdb_pg", this.normalizeError(err));
|
|
445
536
|
}
|
|
446
537
|
}
|
|
447
538
|
/**
|
|
@@ -451,15 +542,15 @@ class K2DB {
|
|
|
451
542
|
*/
|
|
452
543
|
async restore(collectionName, criteria) {
|
|
453
544
|
const collection = await this.getCollection(collectionName);
|
|
454
|
-
criteria
|
|
545
|
+
const query = { ...(criteria || {}), _deleted: true };
|
|
455
546
|
try {
|
|
456
|
-
const res = await collection.updateMany(
|
|
547
|
+
const res = await this.runTimed("updateMany", { collectionName, query }, async () => await collection.updateMany(query, {
|
|
457
548
|
$set: { _deleted: false },
|
|
458
|
-
});
|
|
549
|
+
}));
|
|
459
550
|
return { status: "restored", modified: res.modifiedCount };
|
|
460
551
|
}
|
|
461
552
|
catch (err) {
|
|
462
|
-
throw new
|
|
553
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, "Error restoring a deleted item", "sys_mdb_pres", this.normalizeError(err));
|
|
463
554
|
}
|
|
464
555
|
}
|
|
465
556
|
/**
|
|
@@ -470,11 +561,17 @@ class K2DB {
|
|
|
470
561
|
async count(collectionName, criteria) {
|
|
471
562
|
const collection = await this.getCollection(collectionName);
|
|
472
563
|
try {
|
|
473
|
-
const
|
|
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));
|
|
474
571
|
return { count: cnt };
|
|
475
572
|
}
|
|
476
573
|
catch (err) {
|
|
477
|
-
throw new
|
|
574
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, "Error counting objects with given criteria", "sys_mdb_cn", this.normalizeError(err));
|
|
478
575
|
}
|
|
479
576
|
}
|
|
480
577
|
/**
|
|
@@ -488,7 +585,7 @@ class K2DB {
|
|
|
488
585
|
return { status: "ok" };
|
|
489
586
|
}
|
|
490
587
|
catch (err) {
|
|
491
|
-
throw new
|
|
588
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, "Error dropping collection", "sys_mdb_drop", this.normalizeError(err));
|
|
492
589
|
}
|
|
493
590
|
}
|
|
494
591
|
/**
|
|
@@ -512,6 +609,65 @@ class K2DB {
|
|
|
512
609
|
}
|
|
513
610
|
return criteria;
|
|
514
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
|
+
}
|
|
515
671
|
/**
|
|
516
672
|
* Optional: Executes a transaction with the provided operations.
|
|
517
673
|
* @param operations - A function that performs operations within a transaction session.
|
|
@@ -544,7 +700,7 @@ class K2DB {
|
|
|
544
700
|
debug(`Index created on ${collectionName}: ${JSON.stringify(indexSpec)}`);
|
|
545
701
|
}
|
|
546
702
|
catch (err) {
|
|
547
|
-
throw new
|
|
703
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, `Error creating index on ${collectionName}`, "sys_mdb_idx", this.normalizeError(err));
|
|
548
704
|
}
|
|
549
705
|
}
|
|
550
706
|
/**
|
|
@@ -569,7 +725,7 @@ class K2DB {
|
|
|
569
725
|
debug("Database dropped successfully");
|
|
570
726
|
}
|
|
571
727
|
catch (err) {
|
|
572
|
-
throw new
|
|
728
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, "Error dropping database", "sys_mdb_drop_db", this.normalizeError(err));
|
|
573
729
|
}
|
|
574
730
|
}
|
|
575
731
|
/**
|
|
@@ -580,15 +736,15 @@ class K2DB {
|
|
|
580
736
|
validateCollectionName(collectionName) {
|
|
581
737
|
// Check for null character
|
|
582
738
|
if (collectionName.includes("\0")) {
|
|
583
|
-
throw new
|
|
739
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Collection name cannot contain null characters", "sys_mdb_invalid_collection_name");
|
|
584
740
|
}
|
|
585
741
|
// Check if it starts with 'system.'
|
|
586
742
|
if (collectionName.startsWith("system.")) {
|
|
587
|
-
throw new
|
|
743
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Collection name cannot start with 'system.'", "sys_mdb_invalid_collection_name");
|
|
588
744
|
}
|
|
589
745
|
// Check for invalid characters (e.g., '$')
|
|
590
746
|
if (collectionName.includes("$")) {
|
|
591
|
-
throw new
|
|
747
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Collection name cannot contain the '$' character", "sys_mdb_invalid_collection_name");
|
|
592
748
|
}
|
|
593
749
|
// Additional checks can be added here as needed
|
|
594
750
|
}
|
|
@@ -612,5 +768,136 @@ class K2DB {
|
|
|
612
768
|
normalizeError(err) {
|
|
613
769
|
return err instanceof Error ? err : new Error(String(err));
|
|
614
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
|
+
}
|
|
615
903
|
}
|
|
616
|
-
exports.K2DB = K2DB;
|