@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.
Files changed (6) hide show
  1. package/README.md +297 -1
  2. package/data.d.ts +27 -24
  3. package/data.js +33 -5
  4. package/db.d.ts +104 -28
  5. package/db.js +435 -148
  6. package/package.json +18 -11
package/db.js CHANGED
@@ -1,16 +1,15 @@
1
- "use strict";
2
1
  // src/db.ts
3
- var __importDefault = (this && this.__importDefault) || function (mod) {
4
- return (mod && mod.__esModule) ? mod : { "default": mod };
5
- };
6
- Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.K2DB = void 0;
8
- const k2error_1 = require("@frogfish/k2error"); // Keep the existing error structure
9
- const mongodb_1 = require("mongodb");
10
- const uuid_1 = require("uuid");
11
- const debug_1 = __importDefault(require("debug"));
12
- const debug = (0, debug_1.default)("k2:db");
13
- class K2DB {
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
- // 1. dbName from config
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 = connectUrl.replace(/\/\/.*?:.*?@/, "//*****:*****@");
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 mongodb_1.MongoClient.connect(connectUrl, options);
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, "Failed to connect to MongoDB", "sys_mdb_init", this.normalizeError(err));
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 k2error_1.K2Error) {
106
+ if (err instanceof K2Error) {
104
107
  throw err;
105
108
  }
106
- throw new k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, `Error getting collection: ${collectionName}`, "sys_mdb_gc", this.normalizeError(err));
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, "Error getting the document with provided identity", "sys_mdb_get");
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(criteria, { projection });
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, "Error finding document", "sys_mdb_fo", this.normalizeError(err));
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.includeDeleted) {
160
- // No _deleted filter, include all documents
161
- }
162
- else if (params.deleted === true) {
163
- criteria._deleted = true; // Explicitly search for deleted documents
164
- }
165
- else {
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 = { _id: 0 }; // Exclude _id by default
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, "Error executing find query", "sys_mdb_find_error", this.normalizeError(err));
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, "Aggregation criteria cannot be empty", "sys_mdb_ag_empty");
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, "Aggregation failed", "sys_mdb_ag", this.normalizeError(err));
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 k2error_1.K2Error(k2error_1.ServiceError.BAD_REQUEST, "Invalid method usage, parameters not defined", "sys_mdb_crv1");
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 k2error_1.K2Error(k2error_1.ServiceError.BAD_REQUEST, "Owner must be of a string type", "sys_mdb_crv2");
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 = (0, uuid_1.v4)();
270
- // Spread `data` first, then set internal fields to prevent overwriting
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
- ...data,
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 k2error_1.K2Error(k2error_1.ServiceError.ALREADY_EXISTS, `A document with _uuid ${document._uuid} already exists.`, "sys_mdb_crv3");
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, "Error saving object to database", "sys_mdb_sav", this.normalizeError(err));
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, `Error updating ${collectionName}`, "sys_mdb_update1", this.normalizeError(err));
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
- // If more than one document was updated (though this should never happen with a single UUID), throw a SYSTEM_ERROR
364
- if (res.modifiedCount > 1) {
365
- throw new k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, `Multiple objects in ${collectionName} were updated when only one was expected`, "sys_mdb_update_multiple_found");
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
- // Return updated: 0 if no documents were modified (though this is unlikely)
368
- return { updated: 0 };
459
+ return { updated: res.modifiedCount };
369
460
  }
370
461
  catch (err) {
371
- if (err instanceof k2error_1.K2Error) {
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, `Error updating ${collectionName}`, "sys_mdb_update_error", this.normalizeError(err));
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, `Error updating ${collectionName}`, "sys_mdb_deleteall_update", this.normalizeError(err));
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 k2error_1.K2Error(k2error_1.ServiceError.NOT_FOUND, "Document not found", "sys_mdb_remove_not_found");
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, "Multiple documents deleted when only one was expected", "sys_mdb_remove_multiple_deleted");
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, "Error removing object from collection", "sys_mdb_remove_upd", this.normalizeError(err));
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, "Cannot purge item that is not deleted", "sys_mdb_gcol_pg2");
526
+ throw new K2Error(ServiceError.SYSTEM_ERROR, "Cannot purge item that is not deleted", "sys_mdb_gcol_pg2");
436
527
  }
437
- await collection.deleteMany({ _uuid: id });
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 k2error_1.K2Error) {
532
+ if (err instanceof K2Error) {
442
533
  throw err;
443
534
  }
444
- throw new k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, `Error purging item with id: ${id}`, "sys_mdb_pg", this.normalizeError(err));
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._deleted = true;
545
+ const query = { ...(criteria || {}), _deleted: true };
455
546
  try {
456
- const res = await collection.updateMany(criteria, {
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, "Error restoring a deleted item", "sys_mdb_pres", this.normalizeError(err));
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 cnt = await collection.countDocuments(criteria);
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, "Error counting objects with given criteria", "sys_mdb_cn", this.normalizeError(err));
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, "Error dropping collection", "sys_mdb_drop", this.normalizeError(err));
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, `Error creating index on ${collectionName}`, "sys_mdb_idx", this.normalizeError(err));
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 k2error_1.K2Error(k2error_1.ServiceError.SYSTEM_ERROR, "Error dropping database", "sys_mdb_drop_db", this.normalizeError(err));
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 k2error_1.K2Error(k2error_1.ServiceError.BAD_REQUEST, "Collection name cannot contain null characters", "sys_mdb_invalid_collection_name");
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 k2error_1.K2Error(k2error_1.ServiceError.BAD_REQUEST, "Collection name cannot start with 'system.'", "sys_mdb_invalid_collection_name");
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 k2error_1.K2Error(k2error_1.ServiceError.BAD_REQUEST, "Collection name cannot contain the '$' character", "sys_mdb_invalid_collection_name");
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;