@frogfish/k2db 2.0.7 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +391 -21
- package/{dist/db.d.ts → db.d.ts} +144 -28
- package/db.js +1761 -0
- package/package.json +13 -35
- package/dist/LICENSE +0 -674
- package/dist/README.md +0 -315
- package/dist/db.js +0 -1063
- package/dist/package.json +0 -32
- /package/{dist/data.d.ts → data.d.ts} +0 -0
- /package/{dist/data.js → data.js} +0 -0
package/db.js
ADDED
|
@@ -0,0 +1,1761 @@
|
|
|
1
|
+
// src/db.ts
|
|
2
|
+
import { K2Error, ServiceError, wrap } from "@frogfish/k2error"; // Keep the existing error structure
|
|
3
|
+
import { MongoClient, } from "mongodb";
|
|
4
|
+
import { randomBytes, createHash, createCipheriv, createDecipheriv } from "crypto";
|
|
5
|
+
import { Topic } from '@frogfish/ratatouille';
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
// const debug = debugLib("k2:db");
|
|
8
|
+
const debug = Topic('k2db#random');
|
|
9
|
+
function _hashSecret(secret) {
|
|
10
|
+
return createHash("sha256").update(secret).digest("hex");
|
|
11
|
+
}
|
|
12
|
+
// ---- Shared MongoClient pool (per cluster+auth), reused across DB names ----
|
|
13
|
+
const _clientByKey = new Map();
|
|
14
|
+
const _connectingByKey = new Map();
|
|
15
|
+
const _refCountByKey = new Map();
|
|
16
|
+
function _hostsKey(hosts) {
|
|
17
|
+
const hs = hosts ?? [];
|
|
18
|
+
return hs
|
|
19
|
+
.map((h) => `${h.host}:${h.port ?? ""}`)
|
|
20
|
+
.sort()
|
|
21
|
+
.join(",");
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Cache key for a MongoClient pool. Intentionally excludes `conf.name` (db name),
|
|
25
|
+
* so multiple DBs share the same connection pool.
|
|
26
|
+
*/
|
|
27
|
+
function _clientCacheKey(conf) {
|
|
28
|
+
const user = conf.user ?? "";
|
|
29
|
+
const pass = conf.password ?? "";
|
|
30
|
+
const passKey = pass ? `sha256:${_hashSecret(pass)}` : "";
|
|
31
|
+
const authSource = user && pass ? (conf.authSource ?? "admin") : "";
|
|
32
|
+
const rs = conf.replicaset ?? "";
|
|
33
|
+
const hosts = _hostsKey(conf.hosts);
|
|
34
|
+
return `hosts=${hosts}|user=${user}|pass=${passKey}|authSource=${authSource}|rs=${rs}`;
|
|
35
|
+
}
|
|
36
|
+
async function _acquireClient(key, uri, options) {
|
|
37
|
+
const existing = _clientByKey.get(key);
|
|
38
|
+
if (existing)
|
|
39
|
+
return existing;
|
|
40
|
+
const inflight = _connectingByKey.get(key);
|
|
41
|
+
if (inflight)
|
|
42
|
+
return inflight;
|
|
43
|
+
const p = MongoClient.connect(uri, options)
|
|
44
|
+
.then((client) => {
|
|
45
|
+
_clientByKey.set(key, client);
|
|
46
|
+
_connectingByKey.delete(key);
|
|
47
|
+
return client;
|
|
48
|
+
})
|
|
49
|
+
.catch((err) => {
|
|
50
|
+
_connectingByKey.delete(key);
|
|
51
|
+
throw err;
|
|
52
|
+
});
|
|
53
|
+
_connectingByKey.set(key, p);
|
|
54
|
+
return p;
|
|
55
|
+
}
|
|
56
|
+
function _increfClient(key) {
|
|
57
|
+
_refCountByKey.set(key, (_refCountByKey.get(key) ?? 0) + 1);
|
|
58
|
+
}
|
|
59
|
+
async function _decrefClient(key) {
|
|
60
|
+
const next = (_refCountByKey.get(key) ?? 0) - 1;
|
|
61
|
+
if (next <= 0) {
|
|
62
|
+
_refCountByKey.delete(key);
|
|
63
|
+
const client = _clientByKey.get(key);
|
|
64
|
+
if (client) {
|
|
65
|
+
_clientByKey.delete(key);
|
|
66
|
+
try {
|
|
67
|
+
await client.close();
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
// Best-effort shutdown: never throw from release/close paths.
|
|
71
|
+
debug(`MongoClient close failed for key=${key}: ${err instanceof Error ? err.message : String(err)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
_refCountByKey.set(key, next);
|
|
77
|
+
}
|
|
78
|
+
// ---- End shared MongoClient pool helpers ----
|
|
79
|
+
// Crockford Base32 alphabet (no I, L, O, U)
|
|
80
|
+
const CROCKFORD32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
81
|
+
/**
|
|
82
|
+
* Generates a UUIDv7 (time-ordered) and encodes it as Crockford Base32 with hyphens.
|
|
83
|
+
* Format: 26 base32 chars grouped as 8-4-4-4-6 (total 26)
|
|
84
|
+
*/
|
|
85
|
+
function uuidv7Base32Hyphenated() {
|
|
86
|
+
// 1) Build UUIDv7 bytes
|
|
87
|
+
// Layout per RFC: time_low(32) | time_mid(16) | time_hi_and_version(16) | clock_seq(16) | node(48)
|
|
88
|
+
// Encode 60-bit ms timestamp across time_* fields, version 7, RFC4122 variant in clock_seq_hi.
|
|
89
|
+
const ts = BigInt(Date.now()); // milliseconds
|
|
90
|
+
const timeLow = Number((ts >> 28n) & 0xffffffffn);
|
|
91
|
+
const timeMid = Number((ts >> 12n) & 0xffffn);
|
|
92
|
+
const timeHi = Number(ts & 0xfffn); // lower 12 bits
|
|
93
|
+
const bytes = new Uint8Array(16);
|
|
94
|
+
// time_low (big-endian)
|
|
95
|
+
bytes[0] = (timeLow >>> 24) & 0xff;
|
|
96
|
+
bytes[1] = (timeLow >>> 16) & 0xff;
|
|
97
|
+
bytes[2] = (timeLow >>> 8) & 0xff;
|
|
98
|
+
bytes[3] = timeLow & 0xff;
|
|
99
|
+
// time_mid (big-endian)
|
|
100
|
+
bytes[4] = (timeMid >>> 8) & 0xff;
|
|
101
|
+
bytes[5] = timeMid & 0xff;
|
|
102
|
+
// time_high_and_version: version 7 in high nibble + top 4 bits of timeHi
|
|
103
|
+
bytes[6] = 0x70 | ((timeHi >>> 8) & 0x0f); // 0x7- version
|
|
104
|
+
bytes[7] = timeHi & 0xff;
|
|
105
|
+
// clock_seq + node: 8 random bytes; set RFC4122 variant (10xxxxxx)
|
|
106
|
+
const rnd = randomBytes(8);
|
|
107
|
+
bytes.set(rnd, 8);
|
|
108
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // set variant 10xxxxxx
|
|
109
|
+
// 2) Encode as Crockford Base32 (26 chars). 128 bits -> 26*5 bits (pad 2 high bits)
|
|
110
|
+
let value = 0n;
|
|
111
|
+
for (let i = 0; i < 16; i++) {
|
|
112
|
+
value = (value << 8n) | BigInt(bytes[i]);
|
|
113
|
+
}
|
|
114
|
+
value <<= 2n; // pad to 130 bits so we can take 26 groups cleanly
|
|
115
|
+
let encoded = "";
|
|
116
|
+
for (let i = 25; i >= 0; i--) {
|
|
117
|
+
const idx = Number((value >> BigInt(i * 5)) & 0x1fn);
|
|
118
|
+
encoded += CROCKFORD32[idx];
|
|
119
|
+
}
|
|
120
|
+
// 3) Insert hyphens in groups: 8-4-4-4-6
|
|
121
|
+
return (encoded.slice(0, 8) +
|
|
122
|
+
"-" +
|
|
123
|
+
encoded.slice(8, 12) +
|
|
124
|
+
"-" +
|
|
125
|
+
encoded.slice(12, 16) +
|
|
126
|
+
"-" +
|
|
127
|
+
encoded.slice(16, 20) +
|
|
128
|
+
"-" +
|
|
129
|
+
encoded.slice(20));
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Test helper: fully reset the shared MongoClient pool.
|
|
133
|
+
*
|
|
134
|
+
* Not for production usage; intended for test runners to clean up
|
|
135
|
+
* between suites without restarting the process.
|
|
136
|
+
*/
|
|
137
|
+
export async function resetSharedMongoClientsForTests() {
|
|
138
|
+
const entries = Array.from(_clientByKey.entries());
|
|
139
|
+
for (const [key, client] of entries) {
|
|
140
|
+
try {
|
|
141
|
+
await client.close();
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
debug(`MongoClient close failed during reset for key=${key}: ${err instanceof Error ? err.message : String(err)}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
_clientByKey.clear();
|
|
148
|
+
_connectingByKey.clear();
|
|
149
|
+
_refCountByKey.clear();
|
|
150
|
+
}
|
|
151
|
+
export class K2DB {
|
|
152
|
+
conf;
|
|
153
|
+
db;
|
|
154
|
+
connection;
|
|
155
|
+
clientKey;
|
|
156
|
+
initialized = false;
|
|
157
|
+
initPromise;
|
|
158
|
+
schemas = new Map();
|
|
159
|
+
ownershipMode;
|
|
160
|
+
aggregationMode;
|
|
161
|
+
secureFieldPrefixes;
|
|
162
|
+
secureFieldEncryptionKey;
|
|
163
|
+
secureFieldEncryptionKeyId;
|
|
164
|
+
constructor(conf) {
|
|
165
|
+
this.conf = conf;
|
|
166
|
+
this.ownershipMode = conf.ownershipMode ?? "lax";
|
|
167
|
+
this.aggregationMode = conf.aggregationMode ?? "loose";
|
|
168
|
+
this.secureFieldPrefixes = (conf.secureFieldPrefixes ?? [])
|
|
169
|
+
.map((p) => (typeof p === "string" ? p.trim() : ""))
|
|
170
|
+
.filter((p) => !!p);
|
|
171
|
+
const keyB64 = (conf.secureFieldEncryptionKey ?? "").trim();
|
|
172
|
+
const kid = (conf.secureFieldEncryptionKeyId ?? "").trim();
|
|
173
|
+
this.secureFieldEncryptionKeyId = kid || undefined;
|
|
174
|
+
if (keyB64) {
|
|
175
|
+
let keyBuf;
|
|
176
|
+
try {
|
|
177
|
+
keyBuf = Buffer.from(keyB64, "base64");
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
throw new K2Error(ServiceError.CONFIGURATION_ERROR, "secureFieldEncryptionKey must be base64-encoded", "sys_mdb_secure_key_invalid");
|
|
181
|
+
}
|
|
182
|
+
if (keyBuf.length !== 32) {
|
|
183
|
+
throw new K2Error(ServiceError.CONFIGURATION_ERROR, "secureFieldEncryptionKey must decode to 32 bytes (AES-256)", "sys_mdb_secure_key_invalid");
|
|
184
|
+
}
|
|
185
|
+
this.secureFieldEncryptionKey = keyBuf;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Normalize a scope value for ownership enforcement.
|
|
190
|
+
*/
|
|
191
|
+
normalizeScope(scope) {
|
|
192
|
+
if (scope === undefined || scope === null)
|
|
193
|
+
return undefined;
|
|
194
|
+
if (typeof scope !== "string")
|
|
195
|
+
return undefined;
|
|
196
|
+
const s = scope.trim();
|
|
197
|
+
if (!s)
|
|
198
|
+
return undefined;
|
|
199
|
+
if (s === "*")
|
|
200
|
+
return "*";
|
|
201
|
+
return s.toLowerCase();
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Apply a scope constraint to criteria for ownership enforcement.
|
|
205
|
+
*/
|
|
206
|
+
applyScopeToCriteria(criteria, scope) {
|
|
207
|
+
const normalizedScope = this.normalizeScope(scope);
|
|
208
|
+
// Strict mode requires an explicit scope per call.
|
|
209
|
+
if (this.ownershipMode === "strict" && !normalizedScope) {
|
|
210
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Scope is required in strict ownership mode", "sys_mdb_scope_required");
|
|
211
|
+
}
|
|
212
|
+
// Lax mode with no scope: legacy behavior (no owner constraint injected).
|
|
213
|
+
if (!normalizedScope)
|
|
214
|
+
return criteria;
|
|
215
|
+
// Explicit all-scope request: do not constrain by _owner.
|
|
216
|
+
if (normalizedScope === "*")
|
|
217
|
+
return criteria;
|
|
218
|
+
// If caller already provided _owner in criteria, ensure it matches the scope to avoid ambiguity/bypass.
|
|
219
|
+
if (criteria && typeof criteria === "object" && Object.prototype.hasOwnProperty.call(criteria, "_owner")) {
|
|
220
|
+
const existing = criteria._owner;
|
|
221
|
+
if (typeof existing === "string" && existing.trim().toLowerCase() !== normalizedScope) {
|
|
222
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Conflicting _owner in criteria and provided scope", "sys_mdb_scope_conflict");
|
|
223
|
+
}
|
|
224
|
+
// If it matches (or is non-string), prefer the explicit scope value.
|
|
225
|
+
}
|
|
226
|
+
return { ...(criteria || {}), _owner: normalizedScope };
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Initializes the MongoDB connection.
|
|
230
|
+
*/
|
|
231
|
+
async init() {
|
|
232
|
+
if (this.initialized)
|
|
233
|
+
return;
|
|
234
|
+
if (this.initPromise)
|
|
235
|
+
return this.initPromise;
|
|
236
|
+
this.initPromise = (async () => {
|
|
237
|
+
// Build URI and options
|
|
238
|
+
const { uri, options } = this.buildMongoUri();
|
|
239
|
+
// Mask sensitive information in logs
|
|
240
|
+
const safeConnectUrl = uri.replace(/\/\/.*?:.*?@/, "//*****:*****@");
|
|
241
|
+
debug(`Connecting to MongoDB: ${safeConnectUrl}`);
|
|
242
|
+
try {
|
|
243
|
+
// Establish (or reuse) a shared MongoClient pool per cluster+auth (NOT per db name)
|
|
244
|
+
const key = _clientCacheKey(this.conf);
|
|
245
|
+
const client = await _acquireClient(key, uri, options);
|
|
246
|
+
this.connection = client;
|
|
247
|
+
this.db = this.connection.db(this.conf.name);
|
|
248
|
+
this.clientKey = key;
|
|
249
|
+
if (!this.initialized) {
|
|
250
|
+
_increfClient(key);
|
|
251
|
+
this.initialized = true;
|
|
252
|
+
}
|
|
253
|
+
debug("Successfully connected to MongoDB");
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
// Handle connection error
|
|
257
|
+
const msg = err instanceof Error
|
|
258
|
+
? `Failed to connect to MongoDB: ${err.message}`
|
|
259
|
+
: `Failed to connect to MongoDB: ${String(err)}`;
|
|
260
|
+
throw wrap(err, ServiceError.SERVICE_UNAVAILABLE, "sys_mdb_init", msg);
|
|
261
|
+
}
|
|
262
|
+
})().finally(() => {
|
|
263
|
+
// Allow retry after failure; once initialized, subsequent calls return early.
|
|
264
|
+
this.initPromise = undefined;
|
|
265
|
+
});
|
|
266
|
+
return this.initPromise;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Build a robust MongoDB URI based on config (supports SRV and standard).
|
|
270
|
+
*/
|
|
271
|
+
buildMongoUri() {
|
|
272
|
+
if (!this.conf.hosts || this.conf.hosts.length === 0) {
|
|
273
|
+
throw new K2Error(ServiceError.CONFIGURATION_ERROR, "No valid hosts provided in configuration", "sys_mdb_no_hosts");
|
|
274
|
+
}
|
|
275
|
+
const auth = this.conf.user && this.conf.password
|
|
276
|
+
? `${encodeURIComponent(this.conf.user)}:${encodeURIComponent(this.conf.password)}@`
|
|
277
|
+
: "";
|
|
278
|
+
const singleNoPort = this.conf.hosts.length === 1 && !this.conf.hosts[0].port;
|
|
279
|
+
const useSrv = singleNoPort;
|
|
280
|
+
const dbName = this.conf.name;
|
|
281
|
+
let uri;
|
|
282
|
+
if (useSrv) {
|
|
283
|
+
const host = this.conf.hosts[0].host;
|
|
284
|
+
uri = `mongodb+srv://${auth}${host}/?retryWrites=true&w=majority`;
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
const hostList = this.conf.hosts
|
|
288
|
+
.map((h) => `${h.host}:${h.port || 27017}`)
|
|
289
|
+
.join(",");
|
|
290
|
+
const params = ["retryWrites=true", "w=majority"];
|
|
291
|
+
if (this.conf.replicaset)
|
|
292
|
+
params.push(`replicaSet=${this.conf.replicaset}`);
|
|
293
|
+
uri = `mongodb://${auth}${hostList}/?${params.join("&")}`;
|
|
294
|
+
}
|
|
295
|
+
// Determine authSource based on user and password presence
|
|
296
|
+
const authSource = this.conf.user && this.conf.password
|
|
297
|
+
? this.conf.authSource ?? "admin"
|
|
298
|
+
: undefined;
|
|
299
|
+
const options = {
|
|
300
|
+
connectTimeoutMS: 2000,
|
|
301
|
+
serverSelectionTimeoutMS: 2000,
|
|
302
|
+
...(authSource ? { authSource } : {}),
|
|
303
|
+
};
|
|
304
|
+
return { uri, options };
|
|
305
|
+
}
|
|
306
|
+
/** Load DatabaseConfig from environment variables. */
|
|
307
|
+
static fromEnv(prefix = "K2DB_") {
|
|
308
|
+
const get = (k) => globalThis.process?.env?.[`${prefix}${k}`];
|
|
309
|
+
const name = get("NAME");
|
|
310
|
+
const hostsEnv = get("HOSTS");
|
|
311
|
+
if (!name || !hostsEnv) {
|
|
312
|
+
throw new Error("K2DB_NAME and K2DB_HOSTS are required in environment");
|
|
313
|
+
}
|
|
314
|
+
const hosts = hostsEnv.split(",").map((h) => {
|
|
315
|
+
const [host, port] = h.trim().split(":");
|
|
316
|
+
return { host, port: port ? parseInt(port, 10) : undefined };
|
|
317
|
+
});
|
|
318
|
+
const conf = {
|
|
319
|
+
name,
|
|
320
|
+
hosts,
|
|
321
|
+
user: get("USER"),
|
|
322
|
+
password: get("PASSWORD"),
|
|
323
|
+
authSource: get("AUTH_SOURCE"),
|
|
324
|
+
replicaset: get("REPLICASET"),
|
|
325
|
+
};
|
|
326
|
+
const slow = get("SLOW_MS");
|
|
327
|
+
if (slow)
|
|
328
|
+
conf.slowQueryMs = parseInt(slow, 10);
|
|
329
|
+
return conf;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Retrieves a collection from the database.
|
|
333
|
+
* @param collectionName - Name of the collection.
|
|
334
|
+
*/
|
|
335
|
+
async getCollection(collectionName) {
|
|
336
|
+
try {
|
|
337
|
+
this.validateCollectionName(collectionName); // Validate the collection name
|
|
338
|
+
const collection = this.db.collection(collectionName);
|
|
339
|
+
return collection;
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_gc", `Error getting collection: ${collectionName}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Retrieves a single document by UUID.
|
|
347
|
+
* @param collectionName - Name of the collection.
|
|
348
|
+
* @param uuid - UUID of the document.
|
|
349
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
350
|
+
*/
|
|
351
|
+
async get(collectionName, uuid, scope) {
|
|
352
|
+
const id = K2DB.normalizeId(uuid);
|
|
353
|
+
// Note: findOne() decrypts secure-prefixed fields for single-record reads when encryption is enabled.
|
|
354
|
+
const res = await this.findOne(collectionName, {
|
|
355
|
+
_uuid: id,
|
|
356
|
+
_deleted: { $ne: true },
|
|
357
|
+
}, undefined, scope);
|
|
358
|
+
if (!res) {
|
|
359
|
+
throw new K2Error(ServiceError.NOT_FOUND, "Document not found", "sys_mdb_get_not_found");
|
|
360
|
+
}
|
|
361
|
+
return res;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Retrieves a single document by criteria.
|
|
365
|
+
* @param collectionName - Name of the collection.
|
|
366
|
+
* @param criteria - Criteria to find the document.
|
|
367
|
+
* @param fields - Optional array of fields to include.
|
|
368
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
369
|
+
*/
|
|
370
|
+
async findOne(collectionName, criteria, fields, scope) {
|
|
371
|
+
const collection = await this.getCollection(collectionName);
|
|
372
|
+
const projection = {};
|
|
373
|
+
// Exclude soft-deleted documents by default unless caller specifies otherwise
|
|
374
|
+
const normalizedCriteria = K2DB.normalizeCriteriaIds(criteria || {});
|
|
375
|
+
let query = {
|
|
376
|
+
...normalizedCriteria,
|
|
377
|
+
...(criteria && Object.prototype.hasOwnProperty.call(criteria, "_deleted")
|
|
378
|
+
? {}
|
|
379
|
+
: { _deleted: { $ne: true } }),
|
|
380
|
+
};
|
|
381
|
+
query = this.applyScopeToCriteria(query, scope);
|
|
382
|
+
// If a projection is requested, forbid explicitly projecting secure-prefixed fields.
|
|
383
|
+
// Also ensure _uuid is fetched when secure encryption is enabled so decryption AAD is correct.
|
|
384
|
+
const requestedFields = Array.isArray(fields) ? fields.slice() : undefined;
|
|
385
|
+
if (requestedFields && requestedFields.length > 0) {
|
|
386
|
+
requestedFields.forEach((field) => {
|
|
387
|
+
if (typeof field !== "string")
|
|
388
|
+
return;
|
|
389
|
+
const f = field.trim();
|
|
390
|
+
if (!f)
|
|
391
|
+
return;
|
|
392
|
+
// Deny any projection that includes secure-prefixed fields (including nested dotted paths)
|
|
393
|
+
if (this.pathHasSecureSegment(f)) {
|
|
394
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Projection cannot include secure-prefixed field(s)", "sys_mdb_projection_secure_field");
|
|
395
|
+
}
|
|
396
|
+
projection[f] = 1;
|
|
397
|
+
});
|
|
398
|
+
// Ensure we can decrypt secure fields that are present in the returned doc
|
|
399
|
+
if (this.hasSecureEncryption()) {
|
|
400
|
+
projection["_uuid"] = 1;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
const item = await this.runTimed("findOne", { collectionName, query, projection }, async () => await collection.findOne(query, { projection }));
|
|
405
|
+
if (item) {
|
|
406
|
+
const { _id, ...rest } = item;
|
|
407
|
+
const out = rest;
|
|
408
|
+
const aadBase = out._uuid
|
|
409
|
+
? `k2db|${collectionName}|${out._uuid}`
|
|
410
|
+
: `k2db|${collectionName}`;
|
|
411
|
+
const decrypted = this.decryptSecureFieldsDeep(out, aadBase);
|
|
412
|
+
// If caller requested a projection and did NOT request _uuid, hide it again (it may have been added for decrypt AAD).
|
|
413
|
+
if (requestedFields && requestedFields.length > 0) {
|
|
414
|
+
const wantsUuid = requestedFields.some((f) => typeof f === "string" && f.trim() === "_uuid");
|
|
415
|
+
if (!wantsUuid) {
|
|
416
|
+
delete decrypted._uuid;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return decrypted;
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_fo", "Error finding document");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Finds documents based on parameters with pagination support.
|
|
429
|
+
* @param collectionName - Name of the collection.
|
|
430
|
+
* @param filter - Criteria to filter the documents.
|
|
431
|
+
* @param params - Optional search parameters (for sorting, including/excluding fields).
|
|
432
|
+
* @param skip - Number of documents to skip (for pagination).
|
|
433
|
+
* @param limit - Maximum number of documents to return.
|
|
434
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
435
|
+
*/
|
|
436
|
+
async find(collectionName, filter, params = {}, skip = 0, limit = 100, scope) {
|
|
437
|
+
const collection = await this.getCollection(collectionName);
|
|
438
|
+
// Ensure filter is valid, defaulting to an empty object
|
|
439
|
+
let criteria = { ...(filter || {}) };
|
|
440
|
+
criteria = K2DB.normalizeCriteriaIds(criteria);
|
|
441
|
+
criteria = this.applyScopeToCriteria(criteria, scope);
|
|
442
|
+
// Handle the _deleted field if params specify not to include deleted documents
|
|
443
|
+
if (!params?.includeDeleted && !Object.prototype.hasOwnProperty.call(criteria, "_deleted")) {
|
|
444
|
+
if (params?.deleted === true) {
|
|
445
|
+
criteria._deleted = true; // Explicitly search for deleted documents
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
criteria._deleted = { $ne: true }; // Exclude deleted by default
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// Build projection (fields to include or exclude)
|
|
452
|
+
let projection;
|
|
453
|
+
if (typeof params.filter === "string" && params.filter === "all") {
|
|
454
|
+
projection = {}; // Include all fields
|
|
455
|
+
}
|
|
456
|
+
else if (Array.isArray(params.filter)) {
|
|
457
|
+
projection = {};
|
|
458
|
+
params.filter.forEach((field) => {
|
|
459
|
+
const f = typeof field === "string" ? field.trim() : "";
|
|
460
|
+
if (!f)
|
|
461
|
+
return;
|
|
462
|
+
// Deny any projection that includes secure-prefixed fields (including nested dotted paths)
|
|
463
|
+
if (this.pathHasSecureSegment(f)) {
|
|
464
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Projection cannot include secure-prefixed field(s)", "sys_mdb_projection_secure_field");
|
|
465
|
+
}
|
|
466
|
+
projection[f] = 1; // Only include the specified fields
|
|
467
|
+
});
|
|
468
|
+
projection._id = 0; // Hide _id when using include list
|
|
469
|
+
}
|
|
470
|
+
else if (Array.isArray(params.exclude)) {
|
|
471
|
+
projection = { _id: 0 }; // Start by hiding _id
|
|
472
|
+
params.exclude.forEach((field) => {
|
|
473
|
+
projection[field] = 0; // Exclude the specified fields
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
projection = { _id: 0 }; // Default: hide _id only
|
|
478
|
+
}
|
|
479
|
+
// Build sorting options
|
|
480
|
+
let sort = undefined;
|
|
481
|
+
if (params.order) {
|
|
482
|
+
sort = {};
|
|
483
|
+
for (const [key, value] of Object.entries(params.order)) {
|
|
484
|
+
sort[key] = value === "asc" ? 1 : -1;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
let cursor = collection.find(criteria, { projection });
|
|
489
|
+
// Apply pagination
|
|
490
|
+
cursor = cursor.skip(skip).limit(limit);
|
|
491
|
+
if (sort) {
|
|
492
|
+
cursor = cursor.sort(sort);
|
|
493
|
+
}
|
|
494
|
+
const data = await this.runTimed("find", { collectionName, criteria, projection, sort, skip, limit }, async () => await cursor.toArray());
|
|
495
|
+
// Remove _id safely from each document
|
|
496
|
+
const result = data.map((doc) => {
|
|
497
|
+
const { _id, ...rest } = doc;
|
|
498
|
+
// For multi-record reads, never return secure-prefixed fields (even if encrypted-at-rest is enabled)
|
|
499
|
+
return this.stripSecureFieldsDeep(rest);
|
|
500
|
+
});
|
|
501
|
+
return result;
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_find_error", "Error executing find query");
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Aggregates documents based on criteria with pagination support (may validate/limit stages in guarded/strict aggregationMode). Secure-prefixed fields may be stripped from results when configured.
|
|
509
|
+
* @param collectionName - Name of the collection.
|
|
510
|
+
* @param criteria - Aggregation pipeline criteria.
|
|
511
|
+
* @param skip - Number of documents to skip (for pagination).
|
|
512
|
+
* @param limit - Maximum number of documents to return.
|
|
513
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
514
|
+
*/
|
|
515
|
+
async aggregate(collectionName, criteria, skip = 0, limit = 100, scope) {
|
|
516
|
+
if (criteria.length === 0) {
|
|
517
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, "Aggregation criteria cannot be empty", "sys_mdb_ag_empty");
|
|
518
|
+
}
|
|
519
|
+
// Prevent aggregation from reading/deriving values from secure-prefixed fields (when configured)
|
|
520
|
+
this.assertNoSecureFieldRefsInPipeline(criteria);
|
|
521
|
+
// Validate the aggregation pipeline for allowed/disallowed stages and safety caps
|
|
522
|
+
this.validateAggregationPipeline(criteria, skip, limit);
|
|
523
|
+
// Enforce soft-delete behavior: never return documents marked as deleted
|
|
524
|
+
criteria = K2DB.enforceNoDeletedInPipeline(criteria);
|
|
525
|
+
// Enforce ownership scope within the pipeline (and nested pipelines)
|
|
526
|
+
criteria = this.enforceScopeInPipeline(criteria, scope);
|
|
527
|
+
// Add pagination stages to the aggregation pipeline
|
|
528
|
+
if (skip > 0) {
|
|
529
|
+
criteria.push({ $skip: skip });
|
|
530
|
+
}
|
|
531
|
+
if (limit > 0) {
|
|
532
|
+
criteria.push({ $limit: limit });
|
|
533
|
+
}
|
|
534
|
+
debug(`Aggregating with criteria: ${JSON.stringify(criteria, null, 2)}`);
|
|
535
|
+
const collection = await this.getCollection(collectionName);
|
|
536
|
+
// Sanitize criteria
|
|
537
|
+
const sanitizedCriteria = criteria.map((stage) => {
|
|
538
|
+
if (stage.$match) {
|
|
539
|
+
return K2DB.sanitiseCriteria(stage);
|
|
540
|
+
}
|
|
541
|
+
return stage;
|
|
542
|
+
});
|
|
543
|
+
try {
|
|
544
|
+
const data = await this.runTimed("aggregate", { collectionName, pipeline: sanitizedCriteria }, async () => {
|
|
545
|
+
const mode = this.aggregationMode ?? "loose";
|
|
546
|
+
const opts = {};
|
|
547
|
+
if (mode !== "loose") {
|
|
548
|
+
opts.maxTimeMS = 2000;
|
|
549
|
+
}
|
|
550
|
+
return await collection.aggregate(sanitizedCriteria, opts).toArray();
|
|
551
|
+
});
|
|
552
|
+
// Enforce BaseDocument type on each document and strip secure-prefixed fields (if configured)
|
|
553
|
+
return data.map((doc) => this.stripSecureFieldsDeep(doc));
|
|
554
|
+
}
|
|
555
|
+
catch (err) {
|
|
556
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_ag", "Aggregation failed");
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Validate an aggregation pipeline for safety based on aggregationMode.
|
|
561
|
+
* - loose: no validation
|
|
562
|
+
* - guarded: deny obvious footguns (writes, server-side code) and enforce basic caps
|
|
563
|
+
* - strict: allow only a small safe subset of stages and enforce basic caps
|
|
564
|
+
*/
|
|
565
|
+
validateAggregationPipeline(pipeline, skip, limit) {
|
|
566
|
+
const mode = this.aggregationMode ?? "loose";
|
|
567
|
+
if (mode === "loose")
|
|
568
|
+
return;
|
|
569
|
+
// Hard caps to reduce accidental/abusive heavy queries
|
|
570
|
+
const maxStages = 50;
|
|
571
|
+
if (pipeline.length > maxStages) {
|
|
572
|
+
throw new K2Error(ServiceError.BAD_REQUEST, `Aggregation pipeline too long (max ${maxStages} stages)`, "sys_mdb_ag_pipeline_too_long");
|
|
573
|
+
}
|
|
574
|
+
// Require a positive limit in guarded/strict to avoid accidental full scans.
|
|
575
|
+
// Note: aggregate() appends $limit later; enforce here based on the provided arg.
|
|
576
|
+
if (!(typeof limit === "number") || !isFinite(limit) || limit <= 0) {
|
|
577
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Aggregation requires a positive limit in guarded/strict mode", "sys_mdb_ag_limit_required");
|
|
578
|
+
}
|
|
579
|
+
const maxLimit = 1000;
|
|
580
|
+
if (limit > maxLimit) {
|
|
581
|
+
throw new K2Error(ServiceError.BAD_REQUEST, `Aggregation limit too large (max ${maxLimit})`, "sys_mdb_ag_limit_too_large");
|
|
582
|
+
}
|
|
583
|
+
const ops = this.collectStageOps(pipeline);
|
|
584
|
+
const denyGuarded = new Set(["$out", "$merge", "$function", "$accumulator"]);
|
|
585
|
+
const allowStrict = new Set(["$match", "$project", "$sort", "$skip", "$limit"]);
|
|
586
|
+
if (mode === "guarded") {
|
|
587
|
+
for (const op of ops) {
|
|
588
|
+
if (denyGuarded.has(op)) {
|
|
589
|
+
throw new K2Error(ServiceError.BAD_REQUEST, `Aggregation stage ${op} is not allowed in guarded mode`, "sys_mdb_ag_stage_denied");
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
// strict
|
|
595
|
+
for (const op of ops) {
|
|
596
|
+
if (!allowStrict.has(op)) {
|
|
597
|
+
throw new K2Error(ServiceError.BAD_REQUEST, `Aggregation stage ${op} is not allowed in strict mode`, "sys_mdb_ag_stage_denied");
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/** Collect top-level stage operators for a pipeline (e.g. "$match", "$lookup"). */
|
|
602
|
+
collectStageOps(pipeline) {
|
|
603
|
+
const ops = [];
|
|
604
|
+
for (const stage of pipeline || []) {
|
|
605
|
+
if (!stage || typeof stage !== "object")
|
|
606
|
+
continue;
|
|
607
|
+
const keys = Object.keys(stage);
|
|
608
|
+
if (keys.length === 1 && keys[0].startsWith("$")) {
|
|
609
|
+
ops.push(keys[0]);
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
// Unknown/non-canonical stage shape; treat as invalid in strict/guarded
|
|
613
|
+
ops.push("__invalid__");
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return ops;
|
|
617
|
+
}
|
|
618
|
+
/** True if a field key is considered secure and must not be returned. */
|
|
619
|
+
isSecureFieldKey(key) {
|
|
620
|
+
if (!this.secureFieldPrefixes.length)
|
|
621
|
+
return false;
|
|
622
|
+
for (const p of this.secureFieldPrefixes) {
|
|
623
|
+
if (key.startsWith(p))
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Recursively strips secure-prefixed fields from objects/arrays (e.g. "#passport_number").
|
|
630
|
+
* This is applied on read results (e.g. aggregate) so pipelines cannot exfiltrate secure fields.
|
|
631
|
+
*/
|
|
632
|
+
stripSecureFieldsDeep(val) {
|
|
633
|
+
if (!this.secureFieldPrefixes.length)
|
|
634
|
+
return val;
|
|
635
|
+
if (val === null || val === undefined)
|
|
636
|
+
return val;
|
|
637
|
+
if (Array.isArray(val)) {
|
|
638
|
+
return val.map((x) => this.stripSecureFieldsDeep(x));
|
|
639
|
+
}
|
|
640
|
+
if (typeof val !== "object") {
|
|
641
|
+
return val;
|
|
642
|
+
}
|
|
643
|
+
const out = {};
|
|
644
|
+
for (const [k, v] of Object.entries(val)) {
|
|
645
|
+
if (this.isSecureFieldKey(k))
|
|
646
|
+
continue;
|
|
647
|
+
out[k] = this.stripSecureFieldsDeep(v);
|
|
648
|
+
}
|
|
649
|
+
return out;
|
|
650
|
+
}
|
|
651
|
+
/** True if secure-field encryption is enabled (requires both key and keyId). */
|
|
652
|
+
hasSecureEncryption() {
|
|
653
|
+
return !!this.secureFieldEncryptionKey && !!this.secureFieldEncryptionKeyId;
|
|
654
|
+
}
|
|
655
|
+
/** Encrypt a JS value using AES-256-GCM and return "<kid>:<ivB64>.<tagB64>.<ctB64>". */
|
|
656
|
+
encryptSecureValueToString(value, aad) {
|
|
657
|
+
const kid = this.secureFieldEncryptionKeyId;
|
|
658
|
+
const iv = randomBytes(12);
|
|
659
|
+
const cipher = createCipheriv("aes-256-gcm", this.secureFieldEncryptionKey, iv);
|
|
660
|
+
cipher.setAAD(Buffer.from(aad, "utf8"));
|
|
661
|
+
const pt = Buffer.from(JSON.stringify(value), "utf8");
|
|
662
|
+
const ct = Buffer.concat([cipher.update(pt), cipher.final()]);
|
|
663
|
+
const tag = cipher.getAuthTag();
|
|
664
|
+
const payload = `${iv.toString("base64")}.${tag.toString("base64")}.${ct.toString("base64")}`;
|
|
665
|
+
return `${kid}:${payload}`;
|
|
666
|
+
}
|
|
667
|
+
/** Decrypt a "<kid>:<ivB64>.<tagB64>.<ctB64>" string back into a JS value. */
|
|
668
|
+
decryptSecureStringToValue(s, aad) {
|
|
669
|
+
if (!this.hasSecureEncryption())
|
|
670
|
+
return s;
|
|
671
|
+
if (typeof s !== "string")
|
|
672
|
+
return s;
|
|
673
|
+
const idx = s.indexOf(":");
|
|
674
|
+
if (idx <= 0)
|
|
675
|
+
return s;
|
|
676
|
+
const kid = s.slice(0, idx);
|
|
677
|
+
const payload = s.slice(idx + 1);
|
|
678
|
+
// Only decrypt if kid matches the configured key id
|
|
679
|
+
if (!this.secureFieldEncryptionKeyId || kid !== this.secureFieldEncryptionKeyId) {
|
|
680
|
+
return s;
|
|
681
|
+
}
|
|
682
|
+
const parts = payload.split(".");
|
|
683
|
+
if (parts.length !== 3)
|
|
684
|
+
return s;
|
|
685
|
+
try {
|
|
686
|
+
const iv = Buffer.from(parts[0], "base64");
|
|
687
|
+
const tag = Buffer.from(parts[1], "base64");
|
|
688
|
+
const ct = Buffer.from(parts[2], "base64");
|
|
689
|
+
const decipher = createDecipheriv("aes-256-gcm", this.secureFieldEncryptionKey, iv);
|
|
690
|
+
decipher.setAAD(Buffer.from(aad, "utf8"));
|
|
691
|
+
decipher.setAuthTag(tag);
|
|
692
|
+
const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
693
|
+
return JSON.parse(pt.toString("utf8"));
|
|
694
|
+
}
|
|
695
|
+
catch (err) {
|
|
696
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_secure_decrypt_failed", "Failed to decrypt secure field");
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Encrypt secure-prefixed fields in an object/array tree.
|
|
701
|
+
* Only keys with secure prefixes are encrypted; other keys are recursed to allow nested secure keys.
|
|
702
|
+
*/
|
|
703
|
+
encryptSecureFieldsDeep(val, aadPrefix) {
|
|
704
|
+
if (!this.secureFieldPrefixes.length)
|
|
705
|
+
return val;
|
|
706
|
+
if (!this.hasSecureEncryption())
|
|
707
|
+
return val;
|
|
708
|
+
if (val === null || val === undefined)
|
|
709
|
+
return val;
|
|
710
|
+
if (Array.isArray(val)) {
|
|
711
|
+
return val.map((x) => this.encryptSecureFieldsDeep(x, aadPrefix));
|
|
712
|
+
}
|
|
713
|
+
if (typeof val !== "object")
|
|
714
|
+
return val;
|
|
715
|
+
const out = {};
|
|
716
|
+
for (const [k, v] of Object.entries(val)) {
|
|
717
|
+
if (this.isSecureFieldKey(k)) {
|
|
718
|
+
const aad = `${aadPrefix}|${k}`;
|
|
719
|
+
out[k] = this.encryptSecureValueToString(v, aad);
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
out[k] = this.encryptSecureFieldsDeep(v, aadPrefix);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return out;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Decrypt secure-prefixed fields in an object/array tree.
|
|
729
|
+
* If a secure field value is not an encrypted string, it is returned as-is.
|
|
730
|
+
*/
|
|
731
|
+
decryptSecureFieldsDeep(val, aadPrefix) {
|
|
732
|
+
if (!this.secureFieldPrefixes.length)
|
|
733
|
+
return val;
|
|
734
|
+
if (!this.hasSecureEncryption())
|
|
735
|
+
return val;
|
|
736
|
+
if (val === null || val === undefined)
|
|
737
|
+
return val;
|
|
738
|
+
if (Array.isArray(val)) {
|
|
739
|
+
return val.map((x) => this.decryptSecureFieldsDeep(x, aadPrefix));
|
|
740
|
+
}
|
|
741
|
+
if (typeof val !== "object")
|
|
742
|
+
return val;
|
|
743
|
+
const out = {};
|
|
744
|
+
for (const [k, v] of Object.entries(val)) {
|
|
745
|
+
if (this.isSecureFieldKey(k)) {
|
|
746
|
+
const aad = `${aadPrefix}|${k}`;
|
|
747
|
+
out[k] = typeof v === "string" ? this.decryptSecureStringToValue(v, aad) : v;
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
out[k] = this.decryptSecureFieldsDeep(v, aadPrefix);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return out;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Throws if an aggregation pipeline references any secure-prefixed field paths.
|
|
757
|
+
* This prevents deriving output from secure fields (e.g. {$group: {x: {$sum: "$#passport_number"}}}).
|
|
758
|
+
*/
|
|
759
|
+
assertNoSecureFieldRefsInPipeline(pipeline) {
|
|
760
|
+
if (!this.secureFieldPrefixes.length)
|
|
761
|
+
return;
|
|
762
|
+
if (!Array.isArray(pipeline))
|
|
763
|
+
return;
|
|
764
|
+
if (this.containsSecureFieldRefDeep(pipeline)) {
|
|
765
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Aggregation pipeline references secure-prefixed field(s)", "sys_mdb_ag_secure_field_ref");
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
/** Recursively detects secure field references in an aggregation pipeline AST. */
|
|
769
|
+
containsSecureFieldRefDeep(val) {
|
|
770
|
+
if (!this.secureFieldPrefixes.length)
|
|
771
|
+
return false;
|
|
772
|
+
if (val === null || val === undefined)
|
|
773
|
+
return false;
|
|
774
|
+
if (Array.isArray(val)) {
|
|
775
|
+
return val.some((x) => this.containsSecureFieldRefDeep(x));
|
|
776
|
+
}
|
|
777
|
+
if (typeof val === "string") {
|
|
778
|
+
return this.stringHasSecureFieldPath(val);
|
|
779
|
+
}
|
|
780
|
+
if (typeof val !== "object")
|
|
781
|
+
return false;
|
|
782
|
+
for (const [k, v] of Object.entries(val)) {
|
|
783
|
+
// Object keys can be field names in some expressions; treat them as potential paths too.
|
|
784
|
+
if (this.stringHasSecureFieldPath(k))
|
|
785
|
+
return true;
|
|
786
|
+
if (this.containsSecureFieldRefDeep(v))
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
789
|
+
return false;
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Returns true if a string appears to reference a secure field path.
|
|
793
|
+
* We consider:
|
|
794
|
+
* - "$path.to.field"
|
|
795
|
+
* - "$$var.path.to.field"
|
|
796
|
+
* - plain "path.to.field" (e.g. $getField: { field: "#passport_number" })
|
|
797
|
+
*/
|
|
798
|
+
stringHasSecureFieldPath(s) {
|
|
799
|
+
if (!this.secureFieldPrefixes.length)
|
|
800
|
+
return false;
|
|
801
|
+
if (typeof s !== "string")
|
|
802
|
+
return false;
|
|
803
|
+
const raw = s.trim();
|
|
804
|
+
if (!raw)
|
|
805
|
+
return false;
|
|
806
|
+
// Handle "$$var.path" form (aggregation variables)
|
|
807
|
+
if (raw.startsWith("$$")) {
|
|
808
|
+
const after = raw.slice(2);
|
|
809
|
+
const dot = after.indexOf(".");
|
|
810
|
+
if (dot === -1)
|
|
811
|
+
return false; // just a variable name
|
|
812
|
+
const path = after.slice(dot + 1);
|
|
813
|
+
return this.pathHasSecureSegment(path);
|
|
814
|
+
}
|
|
815
|
+
// Handle "$path" form (field paths)
|
|
816
|
+
if (raw.startsWith("$")) {
|
|
817
|
+
const path = raw.slice(1);
|
|
818
|
+
return this.pathHasSecureSegment(path);
|
|
819
|
+
}
|
|
820
|
+
// Handle plain "path" strings (e.g. $getField field names)
|
|
821
|
+
return this.pathHasSecureSegment(raw);
|
|
822
|
+
}
|
|
823
|
+
/** True if any segment in a dotted path starts with a secure prefix. */
|
|
824
|
+
pathHasSecureSegment(path) {
|
|
825
|
+
const p = path.trim();
|
|
826
|
+
if (!p)
|
|
827
|
+
return false;
|
|
828
|
+
const segments = p.split(".");
|
|
829
|
+
for (const seg of segments) {
|
|
830
|
+
if (!seg)
|
|
831
|
+
continue;
|
|
832
|
+
if (this.isSecureFieldKey(seg))
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Ensures an aggregation pipeline respects ownership scope for the root
|
|
839
|
+
* collection and any joined collections ($lookup, $unionWith, $graphLookup, $facet).
|
|
840
|
+
*/
|
|
841
|
+
enforceScopeInPipeline(pipeline, scope) {
|
|
842
|
+
const normalizedScope = this.normalizeScope(scope);
|
|
843
|
+
// Strict mode requires an explicit scope per call.
|
|
844
|
+
if (this.ownershipMode === "strict" && !normalizedScope) {
|
|
845
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Scope is required in strict ownership mode", "sys_mdb_scope_required");
|
|
846
|
+
}
|
|
847
|
+
// Lax mode with no scope, or explicit all-scope: no owner constraint injected.
|
|
848
|
+
if (!normalizedScope || normalizedScope === "*") {
|
|
849
|
+
return Array.isArray(pipeline) ? pipeline.map((s) => ({ ...s })) : [];
|
|
850
|
+
}
|
|
851
|
+
const cloned = Array.isArray(pipeline) ? pipeline.map((s) => ({ ...s })) : [];
|
|
852
|
+
// Insert a $match to constrain owner near the start, but after any
|
|
853
|
+
// first-stage-only operators like $search, $geoNear, $vectorSearch.
|
|
854
|
+
const reservedFirst = ["$search", "$geoNear", "$vectorSearch"];
|
|
855
|
+
let insertIdx = 0;
|
|
856
|
+
while (insertIdx < cloned.length &&
|
|
857
|
+
typeof cloned[insertIdx] === "object" &&
|
|
858
|
+
cloned[insertIdx] !== null &&
|
|
859
|
+
Object.keys(cloned[insertIdx]).length === 1 &&
|
|
860
|
+
reservedFirst.includes(Object.keys(cloned[insertIdx])[0])) {
|
|
861
|
+
insertIdx++;
|
|
862
|
+
}
|
|
863
|
+
const ownerMatch = { $match: { _owner: normalizedScope } };
|
|
864
|
+
cloned.splice(insertIdx, 0, ownerMatch);
|
|
865
|
+
const mapStage = (stage) => {
|
|
866
|
+
if (!stage || typeof stage !== "object")
|
|
867
|
+
return stage;
|
|
868
|
+
if (stage.$lookup) {
|
|
869
|
+
const lu = { ...stage.$lookup };
|
|
870
|
+
if (Array.isArray(lu.pipeline)) {
|
|
871
|
+
lu.pipeline = this.enforceScopeInPipeline(lu.pipeline, normalizedScope);
|
|
872
|
+
}
|
|
873
|
+
else if (lu.localField && lu.foreignField) {
|
|
874
|
+
// Convert simple lookup to pipeline lookup so we can enforce owner scope (and deleted) in foreign coll.
|
|
875
|
+
const localVar = "__lk";
|
|
876
|
+
const ownerVar = "__own";
|
|
877
|
+
lu.let = { [localVar]: `$${lu.localField}`, [ownerVar]: normalizedScope };
|
|
878
|
+
lu.pipeline = [
|
|
879
|
+
{
|
|
880
|
+
$match: {
|
|
881
|
+
$expr: {
|
|
882
|
+
$and: [
|
|
883
|
+
{
|
|
884
|
+
$cond: [
|
|
885
|
+
{ $isArray: "$$" + localVar },
|
|
886
|
+
{ $in: ["$" + lu.foreignField, "$$" + localVar] },
|
|
887
|
+
{ $eq: ["$" + lu.foreignField, "$$" + localVar] },
|
|
888
|
+
],
|
|
889
|
+
},
|
|
890
|
+
{ $eq: ["$_owner", "$$" + ownerVar] },
|
|
891
|
+
{ $ne: ["$_deleted", true] },
|
|
892
|
+
],
|
|
893
|
+
},
|
|
894
|
+
},
|
|
895
|
+
},
|
|
896
|
+
];
|
|
897
|
+
delete lu.localField;
|
|
898
|
+
delete lu.foreignField;
|
|
899
|
+
}
|
|
900
|
+
return { $lookup: lu };
|
|
901
|
+
}
|
|
902
|
+
if (stage.$unionWith) {
|
|
903
|
+
const uw = stage.$unionWith;
|
|
904
|
+
if (typeof uw === "string") {
|
|
905
|
+
return {
|
|
906
|
+
$unionWith: {
|
|
907
|
+
coll: uw,
|
|
908
|
+
pipeline: [
|
|
909
|
+
{ $match: { _owner: normalizedScope } },
|
|
910
|
+
{ $match: { _deleted: { $ne: true } } },
|
|
911
|
+
],
|
|
912
|
+
},
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
else if (uw && typeof uw === "object") {
|
|
916
|
+
const uwc = { ...uw };
|
|
917
|
+
uwc.pipeline = this.enforceScopeInPipeline(uwc.pipeline || [], normalizedScope);
|
|
918
|
+
return { $unionWith: uwc };
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
if (stage.$graphLookup) {
|
|
922
|
+
const gl = { ...stage.$graphLookup };
|
|
923
|
+
const existing = gl.restrictSearchWithMatch || {};
|
|
924
|
+
gl.restrictSearchWithMatch = { ...existing, _owner: normalizedScope };
|
|
925
|
+
return { $graphLookup: gl };
|
|
926
|
+
}
|
|
927
|
+
if (stage.$facet) {
|
|
928
|
+
const facets = { ...stage.$facet };
|
|
929
|
+
for (const key of Object.keys(facets)) {
|
|
930
|
+
facets[key] = this.enforceScopeInPipeline(facets[key] || [], normalizedScope);
|
|
931
|
+
}
|
|
932
|
+
return { $facet: facets };
|
|
933
|
+
}
|
|
934
|
+
return stage;
|
|
935
|
+
};
|
|
936
|
+
return cloned.map(mapStage);
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Ensures an aggregation pipeline excludes soft-deleted documents for the root
|
|
940
|
+
* collection and any joined collections ($lookup, $unionWith, $graphLookup, $facet).
|
|
941
|
+
*/
|
|
942
|
+
static enforceNoDeletedInPipeline(pipeline) {
|
|
943
|
+
const cloned = Array.isArray(pipeline) ? pipeline.map((s) => ({ ...s })) : [];
|
|
944
|
+
// Insert a $match to exclude deleted near the start, but after any
|
|
945
|
+
// first-stage-only operators like $search, $geoNear, $vectorSearch.
|
|
946
|
+
const reservedFirst = ["$search", "$geoNear", "$vectorSearch"];
|
|
947
|
+
let insertIdx = 0;
|
|
948
|
+
while (insertIdx < cloned.length &&
|
|
949
|
+
typeof cloned[insertIdx] === "object" &&
|
|
950
|
+
cloned[insertIdx] !== null &&
|
|
951
|
+
Object.keys(cloned[insertIdx]).length === 1 &&
|
|
952
|
+
reservedFirst.includes(Object.keys(cloned[insertIdx])[0])) {
|
|
953
|
+
insertIdx++;
|
|
954
|
+
}
|
|
955
|
+
const nonDeletedMatch = { $match: { _deleted: { $ne: true } } };
|
|
956
|
+
cloned.splice(insertIdx, 0, nonDeletedMatch);
|
|
957
|
+
// Walk stages and enforce inside nested pipelines
|
|
958
|
+
const mapStage = (stage) => {
|
|
959
|
+
if (!stage || typeof stage !== "object")
|
|
960
|
+
return stage;
|
|
961
|
+
if (stage.$lookup) {
|
|
962
|
+
const lu = { ...stage.$lookup };
|
|
963
|
+
if (Array.isArray(lu.pipeline)) {
|
|
964
|
+
// Ensure the foreign pipeline excludes deleted
|
|
965
|
+
lu.pipeline = K2DB.enforceNoDeletedInPipeline(lu.pipeline);
|
|
966
|
+
}
|
|
967
|
+
else if (lu.localField && lu.foreignField) {
|
|
968
|
+
// Convert simple lookup to pipeline lookup to filter _deleted
|
|
969
|
+
const localVar = "__lk";
|
|
970
|
+
lu.let = { [localVar]: `$${lu.localField}` };
|
|
971
|
+
lu.pipeline = [
|
|
972
|
+
{
|
|
973
|
+
$match: {
|
|
974
|
+
$expr: {
|
|
975
|
+
$and: [
|
|
976
|
+
{
|
|
977
|
+
$cond: [
|
|
978
|
+
{ $isArray: "$$" + localVar },
|
|
979
|
+
{ $in: ["$" + lu.foreignField, "$$" + localVar] },
|
|
980
|
+
{ $eq: ["$" + lu.foreignField, "$$" + localVar] },
|
|
981
|
+
],
|
|
982
|
+
},
|
|
983
|
+
{ $ne: ["$_deleted", true] },
|
|
984
|
+
],
|
|
985
|
+
},
|
|
986
|
+
},
|
|
987
|
+
},
|
|
988
|
+
];
|
|
989
|
+
delete lu.localField;
|
|
990
|
+
delete lu.foreignField;
|
|
991
|
+
}
|
|
992
|
+
return { $lookup: lu };
|
|
993
|
+
}
|
|
994
|
+
if (stage.$unionWith) {
|
|
995
|
+
const uw = stage.$unionWith;
|
|
996
|
+
if (typeof uw === "string") {
|
|
997
|
+
return {
|
|
998
|
+
$unionWith: {
|
|
999
|
+
coll: uw,
|
|
1000
|
+
pipeline: [{ $match: { _deleted: { $ne: true } } }],
|
|
1001
|
+
},
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
else if (uw && typeof uw === "object") {
|
|
1005
|
+
const uwc = { ...uw };
|
|
1006
|
+
uwc.pipeline = K2DB.enforceNoDeletedInPipeline(uwc.pipeline || []);
|
|
1007
|
+
return { $unionWith: uwc };
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
if (stage.$graphLookup) {
|
|
1011
|
+
const gl = { ...stage.$graphLookup };
|
|
1012
|
+
const existing = gl.restrictSearchWithMatch || {};
|
|
1013
|
+
gl.restrictSearchWithMatch = { ...existing, _deleted: { $ne: true } };
|
|
1014
|
+
return { $graphLookup: gl };
|
|
1015
|
+
}
|
|
1016
|
+
if (stage.$facet) {
|
|
1017
|
+
const facets = { ...stage.$facet };
|
|
1018
|
+
for (const key of Object.keys(facets)) {
|
|
1019
|
+
facets[key] = K2DB.enforceNoDeletedInPipeline(facets[key] || []);
|
|
1020
|
+
}
|
|
1021
|
+
return { $facet: facets };
|
|
1022
|
+
}
|
|
1023
|
+
return stage;
|
|
1024
|
+
};
|
|
1025
|
+
return cloned.map(mapStage);
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Creates a new document in the collection.
|
|
1029
|
+
* @param collectionName - Name of the collection.
|
|
1030
|
+
* @param owner - Owner of the document.
|
|
1031
|
+
* @param data - Data to insert.
|
|
1032
|
+
*/
|
|
1033
|
+
async create(collectionName, owner, data) {
|
|
1034
|
+
if (!collectionName || !owner || !data) {
|
|
1035
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Invalid method usage, parameters not defined", "sys_mdb_crv1");
|
|
1036
|
+
}
|
|
1037
|
+
if (typeof owner !== "string") {
|
|
1038
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Owner must be of a string type", "sys_mdb_crv2");
|
|
1039
|
+
}
|
|
1040
|
+
const normalizedOwner = owner.trim().toLowerCase();
|
|
1041
|
+
if (!normalizedOwner) {
|
|
1042
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Owner must be a non-empty string", "sys_mdb_owner_empty");
|
|
1043
|
+
}
|
|
1044
|
+
if (normalizedOwner === "*") {
|
|
1045
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Owner cannot be '*'", "sys_mdb_owner_star");
|
|
1046
|
+
}
|
|
1047
|
+
const collection = await this.getCollection(collectionName);
|
|
1048
|
+
const timestamp = Date.now();
|
|
1049
|
+
// Generate a new UUIDv7 encoded as Crockford Base32 with hyphens
|
|
1050
|
+
const newUuid = uuidv7Base32Hyphenated();
|
|
1051
|
+
// Remove reserved fields from user data, then validate/transform via schema if present
|
|
1052
|
+
const safeData = K2DB.stripReservedFields(data);
|
|
1053
|
+
const validated = this.applySchema(collectionName, safeData, /*partial*/ false);
|
|
1054
|
+
const storedData = this.encryptSecureFieldsDeep(validated, `k2db|${collectionName}|${newUuid}`);
|
|
1055
|
+
// Spread validated data first, then set internal fields to prevent overwriting
|
|
1056
|
+
const document = {
|
|
1057
|
+
...storedData,
|
|
1058
|
+
_created: timestamp,
|
|
1059
|
+
_updated: timestamp,
|
|
1060
|
+
_owner: normalizedOwner,
|
|
1061
|
+
_uuid: newUuid,
|
|
1062
|
+
};
|
|
1063
|
+
try {
|
|
1064
|
+
await this.runTimed("insertOne", { collectionName }, async () => await collection.insertOne(document));
|
|
1065
|
+
return { id: document._uuid };
|
|
1066
|
+
}
|
|
1067
|
+
catch (err) {
|
|
1068
|
+
// Use appropriate error typing
|
|
1069
|
+
// Check if the error is a duplicate key error
|
|
1070
|
+
if (err.code === 11000 && err.keyPattern && err.keyPattern._uuid) {
|
|
1071
|
+
throw new K2Error(ServiceError.ALREADY_EXISTS, `A document with _uuid ${document._uuid} already exists.`, "sys_mdb_crv3");
|
|
1072
|
+
}
|
|
1073
|
+
// Log the error details for debugging
|
|
1074
|
+
debug(`Was trying to insert into collection ${collectionName}, data: ${JSON.stringify(document)}`);
|
|
1075
|
+
debug(err);
|
|
1076
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_sav", "Error saving object to database");
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Updates multiple documents based on criteria.
|
|
1081
|
+
* Can either replace the documents or patch them.
|
|
1082
|
+
* @param collectionName - Name of the collection.
|
|
1083
|
+
* @param criteria - Update criteria.
|
|
1084
|
+
* @param values - Values to update or replace with.
|
|
1085
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
1086
|
+
*/
|
|
1087
|
+
async updateAll(collectionName, criteria, values, scope) {
|
|
1088
|
+
this.validateCollectionName(collectionName);
|
|
1089
|
+
const collection = await this.getCollection(collectionName);
|
|
1090
|
+
debug(`Updating ${collectionName} with criteria: ${JSON.stringify(criteria)}`);
|
|
1091
|
+
// Preserve intent to set _deleted during internal soft-delete operations.
|
|
1092
|
+
// stripReservedFields removes underscore-prefixed keys (by design) to protect
|
|
1093
|
+
// internal fields from user updates. However, deleteAll() legitimately passes
|
|
1094
|
+
// {_deleted: true}. Capture and restore it here.
|
|
1095
|
+
const deletedFlag = Object.prototype.hasOwnProperty.call(values, "_deleted")
|
|
1096
|
+
? values._deleted
|
|
1097
|
+
: undefined;
|
|
1098
|
+
values = K2DB.stripReservedFields(values);
|
|
1099
|
+
values = this.applySchema(collectionName, values, /*partial*/ true);
|
|
1100
|
+
values._updated = Date.now();
|
|
1101
|
+
if (deletedFlag !== undefined) {
|
|
1102
|
+
values._deleted = deletedFlag;
|
|
1103
|
+
}
|
|
1104
|
+
// Encrypt secure-prefixed fields at rest (no-op unless encryption is configured)
|
|
1105
|
+
values = this.encryptSecureFieldsDeep(values, `k2db|${collectionName}`);
|
|
1106
|
+
criteria = K2DB.normalizeCriteriaIds(criteria || {});
|
|
1107
|
+
criteria = this.applyScopeToCriteria(criteria, scope);
|
|
1108
|
+
criteria = {
|
|
1109
|
+
...criteria,
|
|
1110
|
+
_deleted: { $ne: true },
|
|
1111
|
+
};
|
|
1112
|
+
try {
|
|
1113
|
+
const res = await this.runTimed("updateMany", { collectionName, criteria, values }, async () => await collection.updateMany(criteria, { $set: values }));
|
|
1114
|
+
return {
|
|
1115
|
+
updated: res.modifiedCount,
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
catch (err) {
|
|
1119
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_update1", `Error updating ${collectionName}`);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Updates a single document by UUID.
|
|
1124
|
+
* Can either replace the document or patch it.
|
|
1125
|
+
* @param collectionName - Name of the collection.
|
|
1126
|
+
* @param id - UUID string to identify the document.
|
|
1127
|
+
* @param data - Data to update or replace with.
|
|
1128
|
+
* @param replace - If true, replaces the entire document (PUT), otherwise patches (PATCH).
|
|
1129
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
1130
|
+
*/
|
|
1131
|
+
async update(collectionName, id, data, replace = false, scope) {
|
|
1132
|
+
id = K2DB.normalizeId(id);
|
|
1133
|
+
this.validateCollectionName(collectionName);
|
|
1134
|
+
const collection = await this.getCollection(collectionName);
|
|
1135
|
+
data = K2DB.stripReservedFields(data);
|
|
1136
|
+
data = this.applySchema(collectionName, data, /*partial*/ !replace);
|
|
1137
|
+
data._updated = Date.now(); // Set the _updated timestamp
|
|
1138
|
+
// PATCH path: encrypt secure-prefixed fields at rest (no-op unless encryption is configured).
|
|
1139
|
+
// REPLACE path: encrypt after underscore-field merge to avoid double-encrypt.
|
|
1140
|
+
if (!replace) {
|
|
1141
|
+
data = this.encryptSecureFieldsDeep(data, `k2db|${collectionName}|${id}`);
|
|
1142
|
+
}
|
|
1143
|
+
try {
|
|
1144
|
+
let res;
|
|
1145
|
+
// If replacing the document, first get the original document
|
|
1146
|
+
if (replace) {
|
|
1147
|
+
// Get the original document to preserve fields starting with underscore
|
|
1148
|
+
const originalDoc = await this.get(collectionName, id, scope);
|
|
1149
|
+
// Override all fields starting with underscore from the original document
|
|
1150
|
+
const fieldsToPreserve = Object.keys(originalDoc).reduce((acc, key) => {
|
|
1151
|
+
if (key.startsWith("_")) {
|
|
1152
|
+
acc[key] = originalDoc[key];
|
|
1153
|
+
}
|
|
1154
|
+
return acc;
|
|
1155
|
+
}, {});
|
|
1156
|
+
// Merge the preserved fields into the data
|
|
1157
|
+
data = { ...data, ...fieldsToPreserve };
|
|
1158
|
+
data._updated = Date.now();
|
|
1159
|
+
// Encrypt secure-prefixed fields at rest (no-op unless encryption is configured)
|
|
1160
|
+
data = this.encryptSecureFieldsDeep(data, `k2db|${collectionName}|${id}`);
|
|
1161
|
+
// Now replace the document with the merged data
|
|
1162
|
+
res = await this.runTimed("replaceOne", { collectionName, _uuid: id }, async () => await collection.replaceOne(this.applyScopeToCriteria({ _uuid: id, _deleted: { $ne: true } }, scope), data));
|
|
1163
|
+
}
|
|
1164
|
+
else {
|
|
1165
|
+
// If patching, just update specific fields using $set
|
|
1166
|
+
res = await this.runTimed("updateOne", { collectionName, _uuid: id }, async () => await collection.updateOne(this.applyScopeToCriteria({ _uuid: id, _deleted: { $ne: true } }, scope), { $set: data }));
|
|
1167
|
+
}
|
|
1168
|
+
// Use matchedCount to determine existence; modifiedCount indicates actual change
|
|
1169
|
+
if (res.matchedCount === 0) {
|
|
1170
|
+
throw new K2Error(ServiceError.NOT_FOUND, `Object in ${collectionName} with UUID ${id} not found`, "sys_mdb_update_not_found");
|
|
1171
|
+
}
|
|
1172
|
+
return { updated: res.modifiedCount };
|
|
1173
|
+
}
|
|
1174
|
+
catch (err) {
|
|
1175
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_update_error", `Error updating ${collectionName}`);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Removes (soft deletes) multiple documents based on criteria.
|
|
1180
|
+
* @param collectionName - Name of the collection.
|
|
1181
|
+
* @param criteria - Removal criteria.
|
|
1182
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
1183
|
+
*/
|
|
1184
|
+
async deleteAll(collectionName, criteria, scope) {
|
|
1185
|
+
this.validateCollectionName(collectionName);
|
|
1186
|
+
try {
|
|
1187
|
+
const result = await this.updateAll(collectionName, criteria, {
|
|
1188
|
+
_deleted: true,
|
|
1189
|
+
}, scope);
|
|
1190
|
+
return { deleted: result.updated };
|
|
1191
|
+
}
|
|
1192
|
+
catch (err) {
|
|
1193
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_deleteall_update", `Error updating ${collectionName}`);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Removes (soft deletes) a single document by UUID.
|
|
1198
|
+
* @param collectionName - Name of the collection.
|
|
1199
|
+
* @param id - UUID of the document.
|
|
1200
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
1201
|
+
*/
|
|
1202
|
+
async delete(collectionName, id, scope) {
|
|
1203
|
+
id = K2DB.normalizeId(id);
|
|
1204
|
+
try {
|
|
1205
|
+
// Call deleteAll to soft delete the document by UUID
|
|
1206
|
+
const result = await this.deleteAll(collectionName, { _uuid: id }, scope);
|
|
1207
|
+
// Check the result of the deleteAll operation
|
|
1208
|
+
if (result.deleted === 1) {
|
|
1209
|
+
// Successfully deleted one document
|
|
1210
|
+
return { deleted: 1 };
|
|
1211
|
+
}
|
|
1212
|
+
else if (result.deleted === 0) {
|
|
1213
|
+
// No document was found to delete
|
|
1214
|
+
throw new K2Error(ServiceError.NOT_FOUND, "Document not found", "sys_mdb_remove_not_found");
|
|
1215
|
+
}
|
|
1216
|
+
else {
|
|
1217
|
+
// More than one document was deleted, which is unexpected
|
|
1218
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, "Multiple documents deleted when only one was expected", "sys_mdb_remove_multiple_deleted");
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
catch (err) {
|
|
1222
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_remove_upd", "Error removing object from collection");
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Permanently deletes a document that has been soft-deleted.
|
|
1227
|
+
* @param collectionName - Name of the collection.
|
|
1228
|
+
* @param id - UUID of the document.
|
|
1229
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
1230
|
+
*/
|
|
1231
|
+
async purge(collectionName, id, scope) {
|
|
1232
|
+
id = K2DB.normalizeId(id);
|
|
1233
|
+
const collection = await this.getCollection(collectionName);
|
|
1234
|
+
try {
|
|
1235
|
+
const findFilter = this.applyScopeToCriteria({ _uuid: id, _deleted: true }, scope);
|
|
1236
|
+
const item = await this.runTimed("findOne", { collectionName, ...findFilter }, async () => await collection.findOne(findFilter));
|
|
1237
|
+
if (!item) {
|
|
1238
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, "Cannot purge item that is not deleted", "sys_mdb_gcol_pg2");
|
|
1239
|
+
}
|
|
1240
|
+
const delFilter = this.applyScopeToCriteria({ _uuid: id }, scope);
|
|
1241
|
+
await this.runTimed("deleteOne", { collectionName, ...delFilter }, async () => await collection.deleteOne(delFilter));
|
|
1242
|
+
return { id };
|
|
1243
|
+
}
|
|
1244
|
+
catch (err) {
|
|
1245
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_pg", `Error purging item with id: ${id}`);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Permanently deletes all documents that are soft-deleted and whose _updated
|
|
1250
|
+
* timestamp is older than the provided threshold (in milliseconds ago).
|
|
1251
|
+
* @param collectionName - Name of the collection.
|
|
1252
|
+
* @param olderThanMs - Age threshold in milliseconds; documents with
|
|
1253
|
+
* `_updated <= (Date.now() - olderThanMs)` will be purged.
|
|
1254
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
1255
|
+
*/
|
|
1256
|
+
async purgeDeletedOlderThan(collectionName, olderThanMs, scope) {
|
|
1257
|
+
this.validateCollectionName(collectionName);
|
|
1258
|
+
if (typeof olderThanMs !== 'number' || !isFinite(olderThanMs) || olderThanMs < 0) {
|
|
1259
|
+
throw new K2Error(ServiceError.BAD_REQUEST, 'olderThanMs must be a non-negative number', 'sys_mdb_purge_older_invalid');
|
|
1260
|
+
}
|
|
1261
|
+
const collection = await this.getCollection(collectionName);
|
|
1262
|
+
const cutoff = Date.now() - olderThanMs;
|
|
1263
|
+
try {
|
|
1264
|
+
const delFilter = this.applyScopeToCriteria({
|
|
1265
|
+
_deleted: true,
|
|
1266
|
+
_updated: { $lte: cutoff },
|
|
1267
|
+
}, scope);
|
|
1268
|
+
const res = await this.runTimed('deleteMany', { collectionName, olderThanMs, cutoff, ...delFilter }, async () => await collection.deleteMany(delFilter));
|
|
1269
|
+
return { purged: res.deletedCount ?? 0 };
|
|
1270
|
+
}
|
|
1271
|
+
catch (err) {
|
|
1272
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, 'sys_mdb_purge_older', 'Error purging deleted items by age');
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Restores a soft-deleted document.
|
|
1277
|
+
* @param collectionName - Name of the collection.
|
|
1278
|
+
* @param criteria - Criteria to identify the document.
|
|
1279
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
1280
|
+
*/
|
|
1281
|
+
async restore(collectionName, criteria, scope) {
|
|
1282
|
+
const collection = await this.getCollection(collectionName);
|
|
1283
|
+
let crit = K2DB.normalizeCriteriaIds(criteria || {});
|
|
1284
|
+
crit = this.applyScopeToCriteria(crit, scope);
|
|
1285
|
+
const query = { ...crit, _deleted: true };
|
|
1286
|
+
try {
|
|
1287
|
+
const res = await this.runTimed("updateMany", { collectionName, query }, async () => await collection.updateMany(query, {
|
|
1288
|
+
// Restoring is a data change: flip _deleted and bump _updated
|
|
1289
|
+
$set: { _deleted: false, _updated: Date.now() },
|
|
1290
|
+
}));
|
|
1291
|
+
return { status: "restored", modified: res.modifiedCount };
|
|
1292
|
+
}
|
|
1293
|
+
catch (err) {
|
|
1294
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_pres", "Error restoring a deleted item");
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Counts documents based on criteria.
|
|
1299
|
+
* @param collectionName - Name of the collection.
|
|
1300
|
+
* @param criteria - Counting criteria.
|
|
1301
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
1302
|
+
*/
|
|
1303
|
+
async count(collectionName, criteria, scope) {
|
|
1304
|
+
const collection = await this.getCollection(collectionName);
|
|
1305
|
+
try {
|
|
1306
|
+
const norm = K2DB.normalizeCriteriaIds(criteria || {});
|
|
1307
|
+
let query = {
|
|
1308
|
+
...norm,
|
|
1309
|
+
...(criteria && Object.prototype.hasOwnProperty.call(criteria, "_deleted")
|
|
1310
|
+
? {}
|
|
1311
|
+
: { _deleted: { $ne: true } }),
|
|
1312
|
+
};
|
|
1313
|
+
query = this.applyScopeToCriteria(query, scope);
|
|
1314
|
+
const cnt = await this.runTimed("countDocuments", { collectionName, query }, async () => await collection.countDocuments(query));
|
|
1315
|
+
return { count: cnt };
|
|
1316
|
+
}
|
|
1317
|
+
catch (err) {
|
|
1318
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_cn", "Error counting objects with given criteria");
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* Drops an entire collection (global destructive operation).
|
|
1323
|
+
* @param collectionName - Name of the collection.
|
|
1324
|
+
* @param scope - (optional) Must be "*" in strict ownership mode.
|
|
1325
|
+
*/
|
|
1326
|
+
async drop(collectionName, scope) {
|
|
1327
|
+
const collection = await this.getCollection(collectionName);
|
|
1328
|
+
const normalizedScope = this.normalizeScope(scope);
|
|
1329
|
+
// Global destructive operation: require explicit all-scope gate in strict mode.
|
|
1330
|
+
if (this.ownershipMode === "strict" && normalizedScope !== "*") {
|
|
1331
|
+
throw new K2Error(ServiceError.BAD_REQUEST, 'Dropping a collection requires scope="*" in strict ownership mode', "sys_mdb_drop_scope_required");
|
|
1332
|
+
}
|
|
1333
|
+
// In lax mode, allow legacy behavior when scope is omitted; if provided, it must be "*".
|
|
1334
|
+
if (this.ownershipMode === "lax" && normalizedScope !== undefined && normalizedScope !== "*") {
|
|
1335
|
+
throw new K2Error(ServiceError.BAD_REQUEST, 'Dropping a collection only supports scope="*"', "sys_mdb_drop_scope_invalid");
|
|
1336
|
+
}
|
|
1337
|
+
try {
|
|
1338
|
+
await collection.drop();
|
|
1339
|
+
return { status: "ok" };
|
|
1340
|
+
}
|
|
1341
|
+
catch (err) {
|
|
1342
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_drop", "Error dropping collection");
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Sanitizes aggregation criteria.
|
|
1347
|
+
* @param criteria - Aggregation stage criteria.
|
|
1348
|
+
*/
|
|
1349
|
+
static sanitiseCriteria(criteria) {
|
|
1350
|
+
if (criteria.$match) {
|
|
1351
|
+
// Normalize any _uuid values in the match object to uppercase
|
|
1352
|
+
criteria.$match = K2DB.normalizeCriteriaIds(criteria.$match);
|
|
1353
|
+
for (const key of Object.keys(criteria.$match)) {
|
|
1354
|
+
if (typeof criteria.$match[key] !== "string") {
|
|
1355
|
+
criteria.$match[key] = K2DB.sanitiseCriteria({
|
|
1356
|
+
[key]: criteria.$match[key],
|
|
1357
|
+
})[key];
|
|
1358
|
+
}
|
|
1359
|
+
else {
|
|
1360
|
+
if (key === "$exists") {
|
|
1361
|
+
criteria.$match[key] = criteria.$match[key] === "true";
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
return criteria;
|
|
1367
|
+
}
|
|
1368
|
+
/** Recursively normalizes query fields: `_uuid` uppercased, `_owner` lowercased. */
|
|
1369
|
+
static normalizeCriteriaIds(obj) {
|
|
1370
|
+
if (!obj || typeof obj !== "object")
|
|
1371
|
+
return obj;
|
|
1372
|
+
if (Array.isArray(obj))
|
|
1373
|
+
return obj.map((v) => K2DB.normalizeCriteriaIds(v));
|
|
1374
|
+
const out = Array.isArray(obj) ? [] : { ...obj };
|
|
1375
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
1376
|
+
if (k === "_uuid") {
|
|
1377
|
+
out[k] = K2DB.normalizeUuidField(v);
|
|
1378
|
+
}
|
|
1379
|
+
else if (k === "_owner") {
|
|
1380
|
+
out[k] = K2DB.normalizeOwnerField(v);
|
|
1381
|
+
}
|
|
1382
|
+
else if (v && typeof v === "object") {
|
|
1383
|
+
out[k] = K2DB.normalizeCriteriaIds(v);
|
|
1384
|
+
}
|
|
1385
|
+
else if (Array.isArray(v)) {
|
|
1386
|
+
out[k] = v.map((x) => K2DB.normalizeCriteriaIds(x));
|
|
1387
|
+
}
|
|
1388
|
+
else {
|
|
1389
|
+
out[k] = v;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
return out;
|
|
1393
|
+
}
|
|
1394
|
+
/** Uppercase helper for `_uuid` field supporting operators like $in/$nin/$eq/$ne and arrays. */
|
|
1395
|
+
static normalizeUuidField(val) {
|
|
1396
|
+
if (typeof val === "string")
|
|
1397
|
+
return val.toUpperCase();
|
|
1398
|
+
if (Array.isArray(val))
|
|
1399
|
+
return val.map((x) => (typeof x === "string" ? x.toUpperCase() : x));
|
|
1400
|
+
if (val && typeof val === "object") {
|
|
1401
|
+
const out = { ...val };
|
|
1402
|
+
for (const op of ["$in", "$nin", "$eq", "$ne", "$all"]) {
|
|
1403
|
+
if (op in out)
|
|
1404
|
+
out[op] = K2DB.normalizeUuidField(out[op]);
|
|
1405
|
+
}
|
|
1406
|
+
return out;
|
|
1407
|
+
}
|
|
1408
|
+
return val;
|
|
1409
|
+
}
|
|
1410
|
+
/** Lowercase helper for `_owner` field supporting operators like $in/$nin/$eq/$ne and arrays. */
|
|
1411
|
+
static normalizeOwnerField(val) {
|
|
1412
|
+
if (typeof val === "string")
|
|
1413
|
+
return val.trim().toLowerCase();
|
|
1414
|
+
if (Array.isArray(val)) {
|
|
1415
|
+
return val.map((x) => (typeof x === "string" ? x.trim().toLowerCase() : x));
|
|
1416
|
+
}
|
|
1417
|
+
if (val && typeof val === "object") {
|
|
1418
|
+
const out = { ...val };
|
|
1419
|
+
for (const op of ["$in", "$nin", "$eq", "$ne", "$all"]) {
|
|
1420
|
+
if (op in out)
|
|
1421
|
+
out[op] = K2DB.normalizeOwnerField(out[op]);
|
|
1422
|
+
}
|
|
1423
|
+
return out;
|
|
1424
|
+
}
|
|
1425
|
+
return val;
|
|
1426
|
+
}
|
|
1427
|
+
/** Strip any user-provided fields that start with '_' (reserved). */
|
|
1428
|
+
static stripReservedFields(obj) {
|
|
1429
|
+
const out = {};
|
|
1430
|
+
for (const [k, v] of Object.entries(obj || {})) {
|
|
1431
|
+
if (!k.startsWith("_"))
|
|
1432
|
+
out[k] = v;
|
|
1433
|
+
}
|
|
1434
|
+
return out;
|
|
1435
|
+
}
|
|
1436
|
+
/** True if string matches K2 ID format (Crockford Base32, 8-4-4-4-6, uppercase). */
|
|
1437
|
+
static isK2ID(id) {
|
|
1438
|
+
if (typeof id !== "string")
|
|
1439
|
+
return false;
|
|
1440
|
+
const s = id.trim().toUpperCase();
|
|
1441
|
+
const CROCK_RE = /^[0-9A-HJKMNPQRSTVWXYZ]{8}-[0-9A-HJKMNPQRSTVWXYZ]{4}-[0-9A-HJKMNPQRSTVWXYZ]{4}-[0-9A-HJKMNPQRSTVWXYZ]{4}-[0-9A-HJKMNPQRSTVWXYZ]{6}$/;
|
|
1442
|
+
return CROCK_RE.test(s);
|
|
1443
|
+
}
|
|
1444
|
+
/** Uppercase incoming IDs for case-insensitive lookups. */
|
|
1445
|
+
static normalizeId(id) {
|
|
1446
|
+
return id.toUpperCase();
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Run an async DB operation with timing, slow logging, and hooks.
|
|
1450
|
+
*/
|
|
1451
|
+
async runTimed(op, details, fn) {
|
|
1452
|
+
try {
|
|
1453
|
+
this.conf.hooks?.beforeQuery?.(op, details);
|
|
1454
|
+
}
|
|
1455
|
+
catch { }
|
|
1456
|
+
const start = Date.now();
|
|
1457
|
+
try {
|
|
1458
|
+
const res = await fn();
|
|
1459
|
+
const dur = Date.now() - start;
|
|
1460
|
+
const slow = this.conf.slowQueryMs ?? 200;
|
|
1461
|
+
if (dur > slow) {
|
|
1462
|
+
debug(`[SLOW ${op}] ${dur}ms ${JSON.stringify(details)}`);
|
|
1463
|
+
}
|
|
1464
|
+
try {
|
|
1465
|
+
this.conf.hooks?.afterQuery?.(op, { ...details, ok: true }, dur);
|
|
1466
|
+
}
|
|
1467
|
+
catch { }
|
|
1468
|
+
return res;
|
|
1469
|
+
}
|
|
1470
|
+
catch (e) {
|
|
1471
|
+
const dur = Date.now() - start;
|
|
1472
|
+
try {
|
|
1473
|
+
this.conf.hooks?.afterQuery?.(op, { ...details, ok: false, error: e instanceof Error ? e.message : String(e) }, dur);
|
|
1474
|
+
}
|
|
1475
|
+
catch { }
|
|
1476
|
+
throw e;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Ensure commonly needed indexes exist.
|
|
1481
|
+
*/
|
|
1482
|
+
async ensureIndexes(collectionName, opts = {}) {
|
|
1483
|
+
const { uuidUnique = false, uuidPartialUnique = true, ownerIndex = true, deletedIndex = true, } = opts;
|
|
1484
|
+
const collection = await this.getCollection(collectionName);
|
|
1485
|
+
if (uuidPartialUnique) {
|
|
1486
|
+
// Use a compound unique index to ensure at most one non-deleted document per _uuid
|
|
1487
|
+
// without relying on partialFilterExpression (which may be limited in some environments).
|
|
1488
|
+
await collection.createIndex({ _uuid: 1, _deleted: 1 }, { unique: true });
|
|
1489
|
+
}
|
|
1490
|
+
else if (uuidUnique) {
|
|
1491
|
+
await collection.createIndex({ _uuid: 1 }, { unique: true });
|
|
1492
|
+
}
|
|
1493
|
+
if (ownerIndex) {
|
|
1494
|
+
await collection.createIndex({ _owner: 1 });
|
|
1495
|
+
}
|
|
1496
|
+
if (deletedIndex) {
|
|
1497
|
+
await collection.createIndex({ _deleted: 1 });
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Optional: Executes a transaction with the provided operations.
|
|
1502
|
+
* @param operations - A function that performs operations within a transaction session.
|
|
1503
|
+
*/
|
|
1504
|
+
async executeTransaction(operations) {
|
|
1505
|
+
const session = this.connection.startSession();
|
|
1506
|
+
session.startTransaction();
|
|
1507
|
+
try {
|
|
1508
|
+
await operations(session);
|
|
1509
|
+
await session.commitTransaction();
|
|
1510
|
+
}
|
|
1511
|
+
catch (error) {
|
|
1512
|
+
await session.abortTransaction();
|
|
1513
|
+
throw wrap(error, ServiceError.BAD_GATEWAY, "sys_mdb_txn", "Transaction failed");
|
|
1514
|
+
}
|
|
1515
|
+
finally {
|
|
1516
|
+
session.endSession();
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Optional: Creates an index on the specified collection.
|
|
1521
|
+
* @param collectionName - Name of the collection.
|
|
1522
|
+
* @param indexSpec - Specification of the index.
|
|
1523
|
+
* @param options - Optional index options.
|
|
1524
|
+
*/
|
|
1525
|
+
async createIndex(collectionName, indexSpec, options) {
|
|
1526
|
+
const collection = await this.getCollection(collectionName);
|
|
1527
|
+
try {
|
|
1528
|
+
await collection.createIndex(indexSpec, options);
|
|
1529
|
+
debug(`Index created on ${collectionName}: ${JSON.stringify(indexSpec)}`);
|
|
1530
|
+
}
|
|
1531
|
+
catch (err) {
|
|
1532
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_idx", `Error creating index on ${collectionName}`);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Releases the MongoDB connection.
|
|
1537
|
+
*/
|
|
1538
|
+
async release() {
|
|
1539
|
+
if (this.initialized && this.clientKey) {
|
|
1540
|
+
const key = this.clientKey;
|
|
1541
|
+
this.initialized = false;
|
|
1542
|
+
this.clientKey = undefined;
|
|
1543
|
+
await _decrefClient(key);
|
|
1544
|
+
debug("MongoDB connection released");
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
debug("MongoDB connection released");
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Closes the MongoDB connection.
|
|
1551
|
+
*/
|
|
1552
|
+
close() {
|
|
1553
|
+
// Fire-and-forget async release (shared pool is refcounted)
|
|
1554
|
+
void this.release();
|
|
1555
|
+
}
|
|
1556
|
+
/**
|
|
1557
|
+
* Drops the entire database.
|
|
1558
|
+
*/
|
|
1559
|
+
async dropDatabase() {
|
|
1560
|
+
try {
|
|
1561
|
+
await this.db.dropDatabase();
|
|
1562
|
+
debug("Database dropped successfully");
|
|
1563
|
+
}
|
|
1564
|
+
catch (err) {
|
|
1565
|
+
throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_drop_db", "Error dropping database");
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Validates the MongoDB collection name.
|
|
1570
|
+
* @param collectionName - The name of the collection to validate.
|
|
1571
|
+
* @throws {K2Error} - If the collection name is invalid.
|
|
1572
|
+
*/
|
|
1573
|
+
validateCollectionName(collectionName) {
|
|
1574
|
+
// Check for null character
|
|
1575
|
+
if (collectionName.includes("\0")) {
|
|
1576
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Collection name cannot contain null characters", "sys_mdb_invalid_collection_name");
|
|
1577
|
+
}
|
|
1578
|
+
// Check if it starts with 'system.'
|
|
1579
|
+
if (collectionName.startsWith("system.")) {
|
|
1580
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Collection name cannot start with 'system.'", "sys_mdb_invalid_collection_name");
|
|
1581
|
+
}
|
|
1582
|
+
// Check for invalid characters (e.g., '$')
|
|
1583
|
+
if (collectionName.includes("$")) {
|
|
1584
|
+
throw new K2Error(ServiceError.BAD_REQUEST, "Collection name cannot contain the '$' character", "sys_mdb_invalid_collection_name");
|
|
1585
|
+
}
|
|
1586
|
+
// Additional checks can be added here as needed
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* Optional: Checks the health of the database connection.
|
|
1590
|
+
*/
|
|
1591
|
+
async isHealthy() {
|
|
1592
|
+
try {
|
|
1593
|
+
await this.db.command({ ping: 1 });
|
|
1594
|
+
return true;
|
|
1595
|
+
}
|
|
1596
|
+
catch {
|
|
1597
|
+
return false;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
// ===== Versioning helpers and APIs =====
|
|
1601
|
+
/** Name of the history collection for a given collection. */
|
|
1602
|
+
historyName(collectionName) {
|
|
1603
|
+
return `${collectionName}__history`;
|
|
1604
|
+
}
|
|
1605
|
+
// ===== Zod schema registry (opt-in) =====
|
|
1606
|
+
/** Register a Zod schema for a collection. */
|
|
1607
|
+
setSchema(collectionName, schema, options = {}) {
|
|
1608
|
+
const mode = options.mode ?? "strip";
|
|
1609
|
+
this.schemas.set(collectionName, { schema, mode });
|
|
1610
|
+
}
|
|
1611
|
+
/** Clear a collection's schema. */
|
|
1612
|
+
clearSchema(collectionName) {
|
|
1613
|
+
this.schemas.delete(collectionName);
|
|
1614
|
+
}
|
|
1615
|
+
/** Clear all schemas. */
|
|
1616
|
+
clearSchemas() {
|
|
1617
|
+
this.schemas.clear();
|
|
1618
|
+
}
|
|
1619
|
+
/** Apply registered schema (if any) to data. For updates, partial=true allows partial input. */
|
|
1620
|
+
applySchema(collectionName, data, partial) {
|
|
1621
|
+
const entry = this.schemas.get(collectionName);
|
|
1622
|
+
if (!entry)
|
|
1623
|
+
return data;
|
|
1624
|
+
let s = entry.schema;
|
|
1625
|
+
// If schema is an object, apply unknown key policy and partial when needed
|
|
1626
|
+
if (s instanceof z.ZodObject) {
|
|
1627
|
+
const so = s;
|
|
1628
|
+
const shaped = entry.mode === "strict" ? so.strict() : entry.mode === "passthrough" ? so.passthrough() : so.strip();
|
|
1629
|
+
s = partial ? shaped.partial() : shaped;
|
|
1630
|
+
}
|
|
1631
|
+
else {
|
|
1632
|
+
// Non-object schema: partial has no effect; leave as-is
|
|
1633
|
+
}
|
|
1634
|
+
const parsed = s.safeParse(data);
|
|
1635
|
+
if (!parsed.success) {
|
|
1636
|
+
throw new K2Error(ServiceError.VALIDATION_ERROR, parsed.error.message, "sys_mdb_schema_validation", parsed.error);
|
|
1637
|
+
}
|
|
1638
|
+
return parsed.data;
|
|
1639
|
+
}
|
|
1640
|
+
/** Get the history collection. */
|
|
1641
|
+
async getHistoryCollection(collectionName) {
|
|
1642
|
+
return this.db.collection(this.historyName(collectionName));
|
|
1643
|
+
}
|
|
1644
|
+
/** Ensure indexes for history tracking. */
|
|
1645
|
+
async ensureHistoryIndexes(collectionName) {
|
|
1646
|
+
const hc = await this.getHistoryCollection(collectionName);
|
|
1647
|
+
await hc.createIndex({ _uuid: 1, _v: 1 }, { unique: true });
|
|
1648
|
+
await hc.createIndex({ _uuid: 1, _at: -1 });
|
|
1649
|
+
}
|
|
1650
|
+
/** Compute the next version number for a document. */
|
|
1651
|
+
async nextVersion(collectionName, id) {
|
|
1652
|
+
id = K2DB.normalizeId(id);
|
|
1653
|
+
const hc = await this.getHistoryCollection(collectionName);
|
|
1654
|
+
const last = await hc
|
|
1655
|
+
.find({ _uuid: id })
|
|
1656
|
+
.project({ _v: 1 })
|
|
1657
|
+
.sort({ _v: -1 })
|
|
1658
|
+
.limit(1)
|
|
1659
|
+
.toArray();
|
|
1660
|
+
return last.length ? last[0]._v + 1 : 1;
|
|
1661
|
+
}
|
|
1662
|
+
/** Save a snapshot of the current document into the history collection. */
|
|
1663
|
+
async snapshotCurrent(collectionName, current) {
|
|
1664
|
+
const hc = await this.getHistoryCollection(collectionName);
|
|
1665
|
+
const version = await this.nextVersion(collectionName, current._uuid);
|
|
1666
|
+
const storedSnapshot = this.encryptSecureFieldsDeep(current, `k2db|${collectionName}|${current._uuid}`);
|
|
1667
|
+
await hc.insertOne({
|
|
1668
|
+
_uuid: current._uuid,
|
|
1669
|
+
_v: version,
|
|
1670
|
+
_at: Date.now(),
|
|
1671
|
+
snapshot: storedSnapshot,
|
|
1672
|
+
});
|
|
1673
|
+
return { version };
|
|
1674
|
+
}
|
|
1675
|
+
/**
|
|
1676
|
+
* Update a document and keep the previous version in a history collection.
|
|
1677
|
+
* If maxVersions is provided, prunes oldest snapshots beyond that number.
|
|
1678
|
+
* @param collectionName - Name of the collection.
|
|
1679
|
+
* @param id - UUID string to identify the document.
|
|
1680
|
+
* @param data - Data to update or replace with.
|
|
1681
|
+
* @param replace - If true, replaces the entire document (PUT), otherwise patches (PATCH).
|
|
1682
|
+
* @param maxVersions - Maximum number of versions to keep (optional).
|
|
1683
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
1684
|
+
*/
|
|
1685
|
+
async updateVersioned(collectionName, id, data, replace = false, maxVersions, scope) {
|
|
1686
|
+
id = K2DB.normalizeId(id);
|
|
1687
|
+
// Get current doc (excludes deleted) and snapshot it
|
|
1688
|
+
const current = await this.get(collectionName, id, scope);
|
|
1689
|
+
await this.ensureHistoryIndexes(collectionName);
|
|
1690
|
+
const { version } = await this.snapshotCurrent(collectionName, current);
|
|
1691
|
+
// Perform update
|
|
1692
|
+
const res = await this.update(collectionName, id, data, replace, scope);
|
|
1693
|
+
// Optionally prune old versions
|
|
1694
|
+
if (typeof maxVersions === "number" && maxVersions >= 0) {
|
|
1695
|
+
const hc = await this.getHistoryCollection(collectionName);
|
|
1696
|
+
const count = await hc.countDocuments({ _uuid: id });
|
|
1697
|
+
const overflow = count - maxVersions;
|
|
1698
|
+
if (overflow > 0) {
|
|
1699
|
+
const olds = await hc
|
|
1700
|
+
.find({ _uuid: id })
|
|
1701
|
+
.project({ _v: 1 })
|
|
1702
|
+
.sort({ _v: 1 })
|
|
1703
|
+
.limit(overflow)
|
|
1704
|
+
.toArray();
|
|
1705
|
+
const vs = olds.map((o) => o._v);
|
|
1706
|
+
if (vs.length) {
|
|
1707
|
+
await hc.deleteMany({ _uuid: id, _v: { $in: vs } });
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
return [{ updated: res.updated, versionSaved: version }];
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* List versions (latest first).
|
|
1715
|
+
* @param collectionName - Name of the collection.
|
|
1716
|
+
* @param id - UUID string to identify the document.
|
|
1717
|
+
* @param skip - Number of versions to skip (for pagination).
|
|
1718
|
+
* @param limit - Maximum number of versions to return.
|
|
1719
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
1720
|
+
*/
|
|
1721
|
+
async listVersions(collectionName, id, skip = 0, limit = 20, scope) {
|
|
1722
|
+
id = K2DB.normalizeId(id);
|
|
1723
|
+
// Gate history access by ownership of the current document
|
|
1724
|
+
await this.get(collectionName, id, scope);
|
|
1725
|
+
const hc = await this.getHistoryCollection(collectionName);
|
|
1726
|
+
const rows = await hc
|
|
1727
|
+
.find({ _uuid: id })
|
|
1728
|
+
.project({ _uuid: 1, _v: 1, _at: 1 })
|
|
1729
|
+
.sort({ _v: -1 })
|
|
1730
|
+
.skip(skip)
|
|
1731
|
+
.limit(limit)
|
|
1732
|
+
.toArray();
|
|
1733
|
+
return rows;
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Revert the current document to a specific historical version (preserves metadata).
|
|
1737
|
+
* @param collectionName - Name of the collection.
|
|
1738
|
+
* @param id - UUID string to identify the document.
|
|
1739
|
+
* @param version - Version number to revert to.
|
|
1740
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
1741
|
+
*/
|
|
1742
|
+
async revertToVersion(collectionName, id, version, scope) {
|
|
1743
|
+
id = K2DB.normalizeId(id);
|
|
1744
|
+
// Gate history access by ownership of the current document
|
|
1745
|
+
await this.get(collectionName, id, scope);
|
|
1746
|
+
const hc = await this.getHistoryCollection(collectionName);
|
|
1747
|
+
const row = await hc.findOne({ _uuid: id, _v: version });
|
|
1748
|
+
if (!row) {
|
|
1749
|
+
throw new K2Error(ServiceError.NOT_FOUND, `Version ${version} for ${id} not found`, "sys_mdb_version_not_found");
|
|
1750
|
+
}
|
|
1751
|
+
const snapshot = row.snapshot;
|
|
1752
|
+
const snapshotPlain = this.decryptSecureFieldsDeep(snapshot, `k2db|${collectionName}|${id}`);
|
|
1753
|
+
// Only apply non-underscore fields; metadata is preserved by replace=true path
|
|
1754
|
+
const apply = {};
|
|
1755
|
+
for (const [k, v] of Object.entries(snapshotPlain)) {
|
|
1756
|
+
if (!k.startsWith("_"))
|
|
1757
|
+
apply[k] = v;
|
|
1758
|
+
}
|
|
1759
|
+
return this.update(collectionName, id, apply, true, scope);
|
|
1760
|
+
}
|
|
1761
|
+
}
|