@frogfish/k2db 3.0.2 → 3.0.4
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/data.d.ts +28 -5
- package/data.js +45 -15
- package/db.js +438 -46
- package/package.json +3 -3
package/data.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { K2DB } from "./db.js";
|
|
2
|
-
import type { BaseDocument, CreateResult, UpdateResult, DeleteResult, RestoreResult, CountResult, DropResult, VersionedUpdateResult, VersionInfo } from "./db.js";
|
|
2
|
+
import type { BaseDocument, CreateResult, UpdateResult, DeleteResult, RestoreResult, CountResult, DropResult, PurgeResult, PurgeManyResult, VersionedUpdateResult, VersionInfo } from "./db.js";
|
|
3
3
|
export declare class K2Data {
|
|
4
4
|
private db;
|
|
5
5
|
private owner;
|
|
@@ -68,9 +68,11 @@ export declare class K2Data {
|
|
|
68
68
|
/**
|
|
69
69
|
* Permanently deletes a document that has been soft-deleted.
|
|
70
70
|
*/
|
|
71
|
-
purge(collectionName: string, id: string): Promise<
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
purge(collectionName: string, id: string): Promise<PurgeResult>;
|
|
72
|
+
/**
|
|
73
|
+
* Permanently deletes all soft-deleted documents older than a threshold.
|
|
74
|
+
*/
|
|
75
|
+
purgeDeletedOlderThan(collectionName: string, olderThanMs: number): Promise<PurgeManyResult>;
|
|
74
76
|
/**
|
|
75
77
|
* Restores a soft-deleted document.
|
|
76
78
|
*/
|
|
@@ -83,6 +85,15 @@ export declare class K2Data {
|
|
|
83
85
|
* Drops an entire collection.
|
|
84
86
|
*/
|
|
85
87
|
drop(collectionName: string): Promise<DropResult>;
|
|
88
|
+
/**
|
|
89
|
+
* Ensure commonly needed indexes exist.
|
|
90
|
+
*/
|
|
91
|
+
ensureIndexes(collectionName: string, opts?: {
|
|
92
|
+
uuidUnique?: boolean;
|
|
93
|
+
uuidPartialUnique?: boolean;
|
|
94
|
+
ownerIndex?: boolean;
|
|
95
|
+
deletedIndex?: boolean;
|
|
96
|
+
}): Promise<void>;
|
|
86
97
|
/**
|
|
87
98
|
* Executes a transaction with the provided operations.
|
|
88
99
|
*/
|
|
@@ -95,6 +106,18 @@ export declare class K2Data {
|
|
|
95
106
|
* Drops the entire database.
|
|
96
107
|
*/
|
|
97
108
|
dropDatabase(): Promise<void>;
|
|
109
|
+
/**
|
|
110
|
+
* Releases the MongoDB connection.
|
|
111
|
+
*/
|
|
112
|
+
release(): Promise<void>;
|
|
113
|
+
/**
|
|
114
|
+
* Closes the MongoDB connection.
|
|
115
|
+
*/
|
|
116
|
+
close(): void;
|
|
117
|
+
/**
|
|
118
|
+
* Validates the MongoDB collection name.
|
|
119
|
+
*/
|
|
120
|
+
validateCollectionName(collectionName: string): void;
|
|
98
121
|
/**
|
|
99
122
|
* Checks the health of the database connection.
|
|
100
123
|
*/
|
|
@@ -102,4 +125,4 @@ export declare class K2Data {
|
|
|
102
125
|
}
|
|
103
126
|
export { K2DB } from "./db.js";
|
|
104
127
|
export declare const isK2ID: (id: string) => boolean;
|
|
105
|
-
export type { DatabaseConfig, BaseDocument, CreateResult, UpdateResult, DeleteResult, RestoreResult, CountResult, DropResult, VersionedUpdateResult, VersionInfo, } from "./db.js";
|
|
128
|
+
export type { DatabaseConfig, BaseDocument, CreateResult, UpdateResult, DeleteResult, RestoreResult, CountResult, DropResult, PurgeResult, PurgeManyResult, VersionedUpdateResult, VersionInfo, } from "./db.js";
|
package/data.js
CHANGED
|
@@ -12,7 +12,7 @@ export class K2Data {
|
|
|
12
12
|
* @param uuid - UUID of the document.
|
|
13
13
|
*/
|
|
14
14
|
async get(collectionName, uuid) {
|
|
15
|
-
return this.db.get(collectionName, uuid);
|
|
15
|
+
return this.db.get(collectionName, uuid, this.owner);
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
18
|
* Retrieves a single document matching the criteria.
|
|
@@ -21,19 +21,19 @@ export class K2Data {
|
|
|
21
21
|
* @param fields - Optional fields to include.
|
|
22
22
|
*/
|
|
23
23
|
async findOne(collectionName, criteria, fields) {
|
|
24
|
-
return this.db.findOne(collectionName, criteria, fields);
|
|
24
|
+
return this.db.findOne(collectionName, criteria, fields, this.owner);
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
27
|
* Finds documents based on filter with optional parameters and pagination.
|
|
28
28
|
*/
|
|
29
29
|
async find(collectionName, filter, params, skip, limit) {
|
|
30
|
-
return this.db.find(collectionName, filter, params, skip, limit);
|
|
30
|
+
return this.db.find(collectionName, filter, params, skip, limit, this.owner);
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
33
|
* Aggregates documents based on criteria with pagination support.
|
|
34
34
|
*/
|
|
35
35
|
async aggregate(collectionName, criteria, skip, limit) {
|
|
36
|
-
return this.db.aggregate(collectionName, criteria, skip, limit);
|
|
36
|
+
return this.db.aggregate(collectionName, criteria, skip, limit, this.owner);
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
39
39
|
* Creates a new document in the collection.
|
|
@@ -46,28 +46,28 @@ export class K2Data {
|
|
|
46
46
|
*/
|
|
47
47
|
async updateAll(collectionName, criteria, values) {
|
|
48
48
|
// Ensure it returns { updated: number }
|
|
49
|
-
return this.db.updateAll(collectionName, criteria, values);
|
|
49
|
+
return this.db.updateAll(collectionName, criteria, values, this.owner);
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
52
|
* Updates a single document by UUID.
|
|
53
53
|
*/
|
|
54
54
|
async update(collectionName, id, data, replace = false) {
|
|
55
55
|
// Ensure it returns { updated: number }
|
|
56
|
-
return this.db.update(collectionName, id, data, replace);
|
|
56
|
+
return this.db.update(collectionName, id, data, replace, this.owner);
|
|
57
57
|
}
|
|
58
58
|
/**
|
|
59
59
|
* Updates a single document by UUID and saves the previous version to history.
|
|
60
60
|
*/
|
|
61
61
|
async updateVersioned(collectionName, id, data, replace = false, maxVersions) {
|
|
62
|
-
return this.db.updateVersioned(collectionName, id, data, replace, maxVersions);
|
|
62
|
+
return this.db.updateVersioned(collectionName, id, data, replace, maxVersions, this.owner);
|
|
63
63
|
}
|
|
64
64
|
/** List versions for a document (latest first). */
|
|
65
65
|
async listVersions(collectionName, id, skip, limit) {
|
|
66
|
-
return this.db.listVersions(collectionName, id, skip, limit);
|
|
66
|
+
return this.db.listVersions(collectionName, id, skip, limit, this.owner);
|
|
67
67
|
}
|
|
68
68
|
/** Revert a document to a prior version. */
|
|
69
69
|
async revertToVersion(collectionName, id, version) {
|
|
70
|
-
return this.db.revertToVersion(collectionName, id, version);
|
|
70
|
+
return this.db.revertToVersion(collectionName, id, version, this.owner);
|
|
71
71
|
}
|
|
72
72
|
/** Ensure history collection indexes exist. */
|
|
73
73
|
async ensureHistoryIndexes(collectionName) {
|
|
@@ -90,37 +90,49 @@ export class K2Data {
|
|
|
90
90
|
*/
|
|
91
91
|
async deleteAll(collectionName, criteria) {
|
|
92
92
|
// Ensure it returns { deleted: number }
|
|
93
|
-
return this.db.deleteAll(collectionName, criteria);
|
|
93
|
+
return this.db.deleteAll(collectionName, criteria, this.owner);
|
|
94
94
|
}
|
|
95
95
|
/**
|
|
96
96
|
* Removes (soft deletes) a single document by UUID.
|
|
97
97
|
*/
|
|
98
98
|
async delete(collectionName, id) {
|
|
99
|
-
return this.db.delete(collectionName, id);
|
|
99
|
+
return this.db.delete(collectionName, id, this.owner);
|
|
100
100
|
}
|
|
101
101
|
/**
|
|
102
102
|
* Permanently deletes a document that has been soft-deleted.
|
|
103
103
|
*/
|
|
104
104
|
async purge(collectionName, id) {
|
|
105
|
-
return this.db.purge(collectionName, id);
|
|
105
|
+
return this.db.purge(collectionName, id, this.owner);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Permanently deletes all soft-deleted documents older than a threshold.
|
|
109
|
+
*/
|
|
110
|
+
async purgeDeletedOlderThan(collectionName, olderThanMs) {
|
|
111
|
+
return this.db.purgeDeletedOlderThan(collectionName, olderThanMs, this.owner);
|
|
106
112
|
}
|
|
107
113
|
/**
|
|
108
114
|
* Restores a soft-deleted document.
|
|
109
115
|
*/
|
|
110
116
|
async restore(collectionName, criteria) {
|
|
111
|
-
return this.db.restore(collectionName, criteria);
|
|
117
|
+
return this.db.restore(collectionName, criteria, this.owner);
|
|
112
118
|
}
|
|
113
119
|
/**
|
|
114
120
|
* Counts documents based on criteria.
|
|
115
121
|
*/
|
|
116
122
|
async count(collectionName, criteria) {
|
|
117
|
-
return this.db.count(collectionName, criteria);
|
|
123
|
+
return this.db.count(collectionName, criteria, this.owner);
|
|
118
124
|
}
|
|
119
125
|
/**
|
|
120
126
|
* Drops an entire collection.
|
|
121
127
|
*/
|
|
122
128
|
async drop(collectionName) {
|
|
123
|
-
return this.db.drop(collectionName);
|
|
129
|
+
return this.db.drop(collectionName, this.owner);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Ensure commonly needed indexes exist.
|
|
133
|
+
*/
|
|
134
|
+
async ensureIndexes(collectionName, opts) {
|
|
135
|
+
return this.db.ensureIndexes(collectionName, opts);
|
|
124
136
|
}
|
|
125
137
|
/**
|
|
126
138
|
* Executes a transaction with the provided operations.
|
|
@@ -140,6 +152,24 @@ export class K2Data {
|
|
|
140
152
|
async dropDatabase() {
|
|
141
153
|
return this.db.dropDatabase();
|
|
142
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Releases the MongoDB connection.
|
|
157
|
+
*/
|
|
158
|
+
async release() {
|
|
159
|
+
return this.db.release();
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Closes the MongoDB connection.
|
|
163
|
+
*/
|
|
164
|
+
close() {
|
|
165
|
+
this.db.close();
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Validates the MongoDB collection name.
|
|
169
|
+
*/
|
|
170
|
+
validateCollectionName(collectionName) {
|
|
171
|
+
return this.db.validateCollectionName(collectionName);
|
|
172
|
+
}
|
|
143
173
|
/**
|
|
144
174
|
* Checks the health of the database connection.
|
|
145
175
|
*/
|
package/db.js
CHANGED
|
@@ -1,14 +1,214 @@
|
|
|
1
1
|
// src/db.ts
|
|
2
|
-
import { K2Error, ServiceError, wrap } from "@frogfish/k2error"; // Keep the existing error structure
|
|
2
|
+
import { K2Error, ServiceError, wrap, chain } from "@frogfish/k2error"; // Keep the existing error structure
|
|
3
3
|
import { MongoClient, } from "mongodb";
|
|
4
4
|
import { randomBytes, createHash, createCipheriv, createDecipheriv } from "crypto";
|
|
5
5
|
import { Topic } from '@frogfish/ratatouille';
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
// const debug = debugLib("k2:db");
|
|
8
|
-
const debug = Topic('k2db#random');
|
|
8
|
+
const debug = Topic('k2db:debug#random');
|
|
9
|
+
const error = Topic('k2db:error#random');
|
|
10
|
+
/**
|
|
11
|
+
* Emit a rich DB error event to Ratatouille.
|
|
12
|
+
* - Never throws (logging must not break request path)
|
|
13
|
+
* - Dedupes per error instance (avoid double logging on rethrow)
|
|
14
|
+
* - INTERNAL: may include sensitive diagnostics
|
|
15
|
+
*/
|
|
16
|
+
function emitDbError(err, meta = {}) {
|
|
17
|
+
try {
|
|
18
|
+
if (!error.enabled)
|
|
19
|
+
return;
|
|
20
|
+
// Deduplicate per error instance (non-enumerable marker)
|
|
21
|
+
const MARK = "__k2db_logged";
|
|
22
|
+
if (err && typeof err === "object") {
|
|
23
|
+
const anyErr = err;
|
|
24
|
+
if (anyErr[MARK])
|
|
25
|
+
return;
|
|
26
|
+
try {
|
|
27
|
+
Object.defineProperty(anyErr, MARK, {
|
|
28
|
+
value: true,
|
|
29
|
+
enumerable: false,
|
|
30
|
+
configurable: true,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// ignore marker failures
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const isErr = err instanceof Error;
|
|
38
|
+
const k2 = err instanceof K2Error ? err : undefined;
|
|
39
|
+
// `sensitive` is expected to be non-enumerable on K2Error
|
|
40
|
+
const sensitive = k2 ? k2.sensitive : (isErr ? err.sensitive : undefined);
|
|
41
|
+
const payload = {
|
|
42
|
+
kind: "k2db:error",
|
|
43
|
+
at: Date.now(),
|
|
44
|
+
...meta,
|
|
45
|
+
k2: k2
|
|
46
|
+
? {
|
|
47
|
+
error: k2.error,
|
|
48
|
+
code: k2.code,
|
|
49
|
+
error_description: k2.error_description,
|
|
50
|
+
trace: k2.trace,
|
|
51
|
+
chain: k2.chain,
|
|
52
|
+
}
|
|
53
|
+
: undefined,
|
|
54
|
+
name: isErr ? err.name : undefined,
|
|
55
|
+
message: isErr ? err.message : String(err),
|
|
56
|
+
// If we don't have a K2Error yet, still include a compact Mongo-ish normalization
|
|
57
|
+
mongo: k2 ? undefined : normaliseMongoError(err),
|
|
58
|
+
// If K2Error has a cause (often the raw Mongo error), include it normalized too
|
|
59
|
+
cause: k2?.cause ? normaliseMongoError(k2.cause) : undefined,
|
|
60
|
+
sensitive,
|
|
61
|
+
stack: isErr ? err.stack : undefined,
|
|
62
|
+
};
|
|
63
|
+
// strip undefined
|
|
64
|
+
for (const k of Object.keys(payload))
|
|
65
|
+
if (payload[k] === undefined)
|
|
66
|
+
delete payload[k];
|
|
67
|
+
error(payload);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// never throw from logging
|
|
71
|
+
}
|
|
72
|
+
}
|
|
9
73
|
function _hashSecret(secret) {
|
|
10
74
|
return createHash("sha256").update(secret).digest("hex");
|
|
11
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Normalise MongoDB driver/server errors into a structured, log-friendly shape.
|
|
78
|
+
*
|
|
79
|
+
* This is intended for INTERNAL diagnostics (e.g. attach to K2Error.sensitive).
|
|
80
|
+
* Keep it compact and resilient to unknown error shapes.
|
|
81
|
+
*/
|
|
82
|
+
function normaliseMongoError(err) {
|
|
83
|
+
const MAX_STR = 2000;
|
|
84
|
+
const MAX_KEYS = 50;
|
|
85
|
+
const MAX_ARR = 50;
|
|
86
|
+
const MAX_DEPTH = 4;
|
|
87
|
+
const truncate = (s) => (s.length > MAX_STR ? s.slice(0, MAX_STR) + "…" : s);
|
|
88
|
+
const prune = (val, depth = 0) => {
|
|
89
|
+
if (val === null || val === undefined)
|
|
90
|
+
return val;
|
|
91
|
+
if (depth >= MAX_DEPTH)
|
|
92
|
+
return "[Truncated]";
|
|
93
|
+
const t = typeof val;
|
|
94
|
+
if (t === "string")
|
|
95
|
+
return truncate(val);
|
|
96
|
+
if (t === "number" || t === "boolean" || t === "bigint")
|
|
97
|
+
return val;
|
|
98
|
+
// Avoid serialising functions/symbols
|
|
99
|
+
if (t === "function")
|
|
100
|
+
return "[Function]";
|
|
101
|
+
if (t === "symbol")
|
|
102
|
+
return "[Symbol]";
|
|
103
|
+
if (Array.isArray(val)) {
|
|
104
|
+
const out = val.slice(0, MAX_ARR).map((x) => prune(x, depth + 1));
|
|
105
|
+
if (val.length > MAX_ARR)
|
|
106
|
+
out.push(`[+${val.length - MAX_ARR} more]`);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
if (t === "object") {
|
|
110
|
+
const obj = val;
|
|
111
|
+
// Preserve Buffer in a safe way
|
|
112
|
+
if (typeof Buffer !== "undefined" && Buffer.isBuffer(obj)) {
|
|
113
|
+
return { type: "Buffer", length: obj.length };
|
|
114
|
+
}
|
|
115
|
+
const keys = Object.keys(obj).slice(0, MAX_KEYS);
|
|
116
|
+
const out = {};
|
|
117
|
+
for (const k of keys) {
|
|
118
|
+
// Basic redaction for common secret-ish keys
|
|
119
|
+
const lk = k.toLowerCase();
|
|
120
|
+
if (lk.includes("password") || lk === "pass" || lk.includes("secret") || lk.includes("token")) {
|
|
121
|
+
out[k] = "[REDACTED]";
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
out[k] = prune(obj[k], depth + 1);
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
out[k] = "[Unserialisable]";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (Object.keys(obj).length > MAX_KEYS) {
|
|
132
|
+
out.__truncatedKeys = Object.keys(obj).length - MAX_KEYS;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
return truncate(String(val));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return "[Unknown]";
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
// MongoDB driver often provides these fields on thrown errors
|
|
144
|
+
const anyErr = err;
|
|
145
|
+
const base = {
|
|
146
|
+
name: anyErr?.name ?? (err instanceof Error ? err.name : "UnknownError"),
|
|
147
|
+
message: anyErr?.message ?? (err instanceof Error ? err.message : String(err)),
|
|
148
|
+
};
|
|
149
|
+
const out = {
|
|
150
|
+
...base,
|
|
151
|
+
// common Mongo fields (present on MongoServerError and friends)
|
|
152
|
+
code: typeof anyErr?.code === "number" ? anyErr.code : undefined,
|
|
153
|
+
codeName: typeof anyErr?.codeName === "string" ? anyErr.codeName : undefined,
|
|
154
|
+
errorLabels: Array.isArray(anyErr?.errorLabels) ? anyErr.errorLabels.slice(0, MAX_ARR) : undefined,
|
|
155
|
+
errInfo: anyErr?.errInfo ? prune(anyErr.errInfo, 0) : undefined,
|
|
156
|
+
};
|
|
157
|
+
// Include a compact pruned view of any extra enumerable fields for diagnostics
|
|
158
|
+
if (anyErr && typeof anyErr === "object") {
|
|
159
|
+
out.extra = prune(anyErr, 0);
|
|
160
|
+
}
|
|
161
|
+
// Remove undefined fields for cleanliness
|
|
162
|
+
for (const k of Object.keys(out)) {
|
|
163
|
+
if (out[k] === undefined)
|
|
164
|
+
delete out[k];
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Produce a compact summary of an arbitrary value for INTERNAL diagnostics.
|
|
170
|
+
* Prefer shapes/keys over full payloads to avoid accidental PII/secret logging.
|
|
171
|
+
*/
|
|
172
|
+
function summariseValueShape(val) {
|
|
173
|
+
try {
|
|
174
|
+
if (val === null)
|
|
175
|
+
return { type: "null" };
|
|
176
|
+
if (val === undefined)
|
|
177
|
+
return { type: "undefined" };
|
|
178
|
+
if (Array.isArray(val))
|
|
179
|
+
return { type: "array", length: val.length };
|
|
180
|
+
const t = typeof val;
|
|
181
|
+
if (t !== "object")
|
|
182
|
+
return { type: t };
|
|
183
|
+
const obj = val;
|
|
184
|
+
const keys = Object.keys(obj);
|
|
185
|
+
return { type: "object", keys: keys.slice(0, 50), keyCount: keys.length };
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return { type: "unknown" };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Redact common secret-ish keys from a shallow object for INTERNAL diagnostics.
|
|
193
|
+
* (Still treat returned value as sensitive; this is only to reduce obvious footguns.)
|
|
194
|
+
*/
|
|
195
|
+
function redactShallowSecrets(obj) {
|
|
196
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj))
|
|
197
|
+
return obj;
|
|
198
|
+
const out = { ...obj };
|
|
199
|
+
for (const k of Object.keys(out)) {
|
|
200
|
+
const lk = k.toLowerCase();
|
|
201
|
+
if (lk.includes("password") ||
|
|
202
|
+
lk === "pass" ||
|
|
203
|
+
lk.includes("secret") ||
|
|
204
|
+
lk.includes("token") ||
|
|
205
|
+
lk.includes("apikey") ||
|
|
206
|
+
lk.includes("api_key")) {
|
|
207
|
+
out[k] = "[REDACTED]";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
12
212
|
// ---- Shared MongoClient pool (per cluster+auth), reused across DB names ----
|
|
13
213
|
const _clientByKey = new Map();
|
|
14
214
|
const _connectingByKey = new Map();
|
|
@@ -339,7 +539,13 @@ export class K2DB {
|
|
|
339
539
|
return collection;
|
|
340
540
|
}
|
|
341
541
|
catch (err) {
|
|
342
|
-
|
|
542
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
543
|
+
throw chain(err, "sys_mdb_gc", `Error getting collection: ${collectionName}`, sev, "k2db.getCollection")
|
|
544
|
+
.setSensitive({
|
|
545
|
+
op: "getCollection",
|
|
546
|
+
collection: collectionName,
|
|
547
|
+
mongo: normaliseMongoError(err),
|
|
548
|
+
});
|
|
343
549
|
}
|
|
344
550
|
}
|
|
345
551
|
/**
|
|
@@ -421,7 +627,19 @@ export class K2DB {
|
|
|
421
627
|
return null;
|
|
422
628
|
}
|
|
423
629
|
catch (err) {
|
|
424
|
-
|
|
630
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
631
|
+
throw chain(err, "sys_mdb_fo", "Error finding document", sev, "k2db.findOne")
|
|
632
|
+
.setSensitive({
|
|
633
|
+
op: "findOne",
|
|
634
|
+
collection: collectionName,
|
|
635
|
+
scope,
|
|
636
|
+
queryShape: summariseValueShape(query),
|
|
637
|
+
projectionShape: summariseValueShape(projection),
|
|
638
|
+
// Queries/projections may contain user identifiers and selectors; treat as sensitive.
|
|
639
|
+
queryPreview: redactShallowSecrets(query),
|
|
640
|
+
projectionPreview: redactShallowSecrets(projection),
|
|
641
|
+
mongo: normaliseMongoError(err),
|
|
642
|
+
});
|
|
425
643
|
}
|
|
426
644
|
}
|
|
427
645
|
/**
|
|
@@ -501,7 +719,23 @@ export class K2DB {
|
|
|
501
719
|
return result;
|
|
502
720
|
}
|
|
503
721
|
catch (err) {
|
|
504
|
-
|
|
722
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
723
|
+
throw chain(err, "sys_mdb_find_error", "Error executing find query", sev, "k2db.find")
|
|
724
|
+
.setSensitive({
|
|
725
|
+
op: "find",
|
|
726
|
+
collection: collectionName,
|
|
727
|
+
scope,
|
|
728
|
+
skip,
|
|
729
|
+
limit,
|
|
730
|
+
paramsShape: summariseValueShape(params),
|
|
731
|
+
criteriaShape: summariseValueShape(criteria),
|
|
732
|
+
projectionShape: summariseValueShape(projection),
|
|
733
|
+
sortShape: summariseValueShape(sort),
|
|
734
|
+
// Queries/projections may contain user identifiers and selectors; treat as sensitive.
|
|
735
|
+
criteriaPreview: redactShallowSecrets(criteria),
|
|
736
|
+
projectionPreview: redactShallowSecrets(projection),
|
|
737
|
+
mongo: normaliseMongoError(err),
|
|
738
|
+
});
|
|
505
739
|
}
|
|
506
740
|
}
|
|
507
741
|
/**
|
|
@@ -513,34 +747,63 @@ export class K2DB {
|
|
|
513
747
|
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
514
748
|
*/
|
|
515
749
|
async aggregate(collectionName, criteria, skip = 0, limit = 100, scope) {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
// Sanitize criteria
|
|
537
|
-
const sanitizedCriteria = criteria.map((stage) => {
|
|
538
|
-
if (stage.$match) {
|
|
539
|
-
return K2DB.sanitiseCriteria(stage);
|
|
540
|
-
}
|
|
541
|
-
return stage;
|
|
542
|
-
});
|
|
750
|
+
// --- BEGIN enhanced diagnostics/snapshots ---
|
|
751
|
+
const clonePipeline = (p) => {
|
|
752
|
+
if (!Array.isArray(p))
|
|
753
|
+
return [];
|
|
754
|
+
return p.map((stage) => {
|
|
755
|
+
try {
|
|
756
|
+
return JSON.parse(JSON.stringify(stage));
|
|
757
|
+
}
|
|
758
|
+
catch {
|
|
759
|
+
return stage;
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
};
|
|
763
|
+
const inputPipeline = clonePipeline(criteria);
|
|
764
|
+
const inputStageOps = this.collectStageOps(inputPipeline);
|
|
765
|
+
// --- END enhanced diagnostics/snapshots ---
|
|
766
|
+
// These are populated as we build/execute the pipeline so we can attach them on ANY failure.
|
|
767
|
+
let workingPipeline;
|
|
768
|
+
let sanitizedCriteria;
|
|
769
|
+
let finalStageOps;
|
|
543
770
|
try {
|
|
771
|
+
if (criteria.length === 0) {
|
|
772
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, "Aggregation criteria cannot be empty", "sys_mdb_ag_empty");
|
|
773
|
+
}
|
|
774
|
+
// Prevent aggregation from reading/deriving values from secure-prefixed fields (when configured)
|
|
775
|
+
this.assertNoSecureFieldRefsInPipeline(criteria);
|
|
776
|
+
// Validate the aggregation pipeline for allowed/disallowed stages and safety caps
|
|
777
|
+
this.validateAggregationPipeline(criteria, skip, limit);
|
|
778
|
+
// Enforce soft-delete behavior: never return documents marked as deleted
|
|
779
|
+
workingPipeline = K2DB.enforceNoDeletedInPipeline(criteria);
|
|
780
|
+
// Enforce ownership scope within the pipeline (and nested pipelines)
|
|
781
|
+
workingPipeline = this.enforceScopeInPipeline(workingPipeline, scope);
|
|
782
|
+
// Add pagination stages to the aggregation pipeline
|
|
783
|
+
if (skip > 0) {
|
|
784
|
+
workingPipeline.push({ $skip: skip });
|
|
785
|
+
}
|
|
786
|
+
if (limit > 0) {
|
|
787
|
+
workingPipeline.push({ $limit: limit });
|
|
788
|
+
}
|
|
789
|
+
debug(`Aggregating with criteria: ${JSON.stringify(workingPipeline, null, 2)}`);
|
|
790
|
+
const collection = await this.getCollection(collectionName);
|
|
791
|
+
// Sanitize criteria (do not mutate the working pipeline)
|
|
792
|
+
sanitizedCriteria = workingPipeline.map((stage) => {
|
|
793
|
+
const clonedStage = (() => {
|
|
794
|
+
try {
|
|
795
|
+
return JSON.parse(JSON.stringify(stage));
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
return stage;
|
|
799
|
+
}
|
|
800
|
+
})();
|
|
801
|
+
if (clonedStage && clonedStage.$match) {
|
|
802
|
+
return K2DB.sanitiseCriteria(clonedStage);
|
|
803
|
+
}
|
|
804
|
+
return clonedStage;
|
|
805
|
+
});
|
|
806
|
+
finalStageOps = this.collectStageOps(sanitizedCriteria);
|
|
544
807
|
const data = await this.runTimed("aggregate", { collectionName, pipeline: sanitizedCriteria }, async () => {
|
|
545
808
|
const mode = this.aggregationMode ?? "loose";
|
|
546
809
|
const opts = {};
|
|
@@ -553,7 +816,54 @@ export class K2DB {
|
|
|
553
816
|
return data.map((doc) => this.stripSecureFieldsDeep(doc));
|
|
554
817
|
}
|
|
555
818
|
catch (err) {
|
|
556
|
-
|
|
819
|
+
// If we already have a K2Error (e.g. validation/ownership/secure-field), preserve it.
|
|
820
|
+
// Otherwise chain into a SYSTEM_ERROR with rich internal diagnostics.
|
|
821
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
822
|
+
const k2 = err instanceof K2Error
|
|
823
|
+
? err
|
|
824
|
+
: chain(err, "sys_mdb_ag", "Aggregation failed", sev, "k2db.aggregate");
|
|
825
|
+
// Merge/attach sensitive diagnostics without losing any existing sensitive payload.
|
|
826
|
+
const prevSensitive = k2.sensitive;
|
|
827
|
+
const mergedSensitive = prevSensitive && typeof prevSensitive === "object" ? { ...prevSensitive } : {};
|
|
828
|
+
// Build a rich diagnostic payload (internal-only)
|
|
829
|
+
Object.assign(mergedSensitive, {
|
|
830
|
+
op: "aggregate",
|
|
831
|
+
collection: collectionName,
|
|
832
|
+
scope,
|
|
833
|
+
skip,
|
|
834
|
+
limit,
|
|
835
|
+
ownershipMode: this.ownershipMode,
|
|
836
|
+
aggregationMode: this.aggregationMode,
|
|
837
|
+
// Caller input vs executed pipeline snapshots
|
|
838
|
+
inputStageOps,
|
|
839
|
+
finalStageOps,
|
|
840
|
+
inputPipelineShape: summariseValueShape(inputPipeline),
|
|
841
|
+
workingPipelineShape: summariseValueShape(workingPipeline),
|
|
842
|
+
finalPipelineShape: summariseValueShape(sanitizedCriteria),
|
|
843
|
+
// Full internal pipelines (may include identifiers/selectors)
|
|
844
|
+
inputPipeline,
|
|
845
|
+
workingPipeline,
|
|
846
|
+
pipeline: sanitizedCriteria,
|
|
847
|
+
// Compact at-a-glance previews (shallow key redaction only)
|
|
848
|
+
inputPipelinePreview: Array.isArray(inputPipeline)
|
|
849
|
+
? inputPipeline.map((s) => redactShallowSecrets(s))
|
|
850
|
+
: inputPipeline,
|
|
851
|
+
workingPipelinePreview: Array.isArray(workingPipeline)
|
|
852
|
+
? workingPipeline.map((s) => redactShallowSecrets(s))
|
|
853
|
+
: workingPipeline,
|
|
854
|
+
pipelinePreview: Array.isArray(sanitizedCriteria)
|
|
855
|
+
? sanitizedCriteria.map((s) => redactShallowSecrets(s))
|
|
856
|
+
: sanitizedCriteria,
|
|
857
|
+
mongo: normaliseMongoError(err),
|
|
858
|
+
});
|
|
859
|
+
// setSensitive is expected to exist on K2Error (non-enumerable sensitive field)
|
|
860
|
+
k2.setSensitive?.(mergedSensitive);
|
|
861
|
+
// Emit once (deduped per error instance)
|
|
862
|
+
emitDbError(k2, {
|
|
863
|
+
op: "aggregate",
|
|
864
|
+
collection: collectionName,
|
|
865
|
+
});
|
|
866
|
+
throw k2;
|
|
557
867
|
}
|
|
558
868
|
}
|
|
559
869
|
/**
|
|
@@ -1070,10 +1380,17 @@ export class K2DB {
|
|
|
1070
1380
|
if (err.code === 11000 && err.keyPattern && err.keyPattern._uuid) {
|
|
1071
1381
|
throw new K2Error(ServiceError.ALREADY_EXISTS, `A document with _uuid ${document._uuid} already exists.`, "sys_mdb_crv3");
|
|
1072
1382
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1383
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
1384
|
+
throw chain(err, "sys_mdb_sav", "Error saving object to database", sev, "k2db.create")
|
|
1385
|
+
.setSensitive({
|
|
1386
|
+
op: "insertOne",
|
|
1387
|
+
collection: collectionName,
|
|
1388
|
+
owner: normalizedOwner,
|
|
1389
|
+
uuid: document._uuid,
|
|
1390
|
+
documentShape: summariseValueShape(document),
|
|
1391
|
+
userFieldPreview: redactShallowSecrets(safeData),
|
|
1392
|
+
mongo: normaliseMongoError(err),
|
|
1393
|
+
});
|
|
1077
1394
|
}
|
|
1078
1395
|
}
|
|
1079
1396
|
/**
|
|
@@ -1116,7 +1433,19 @@ export class K2DB {
|
|
|
1116
1433
|
};
|
|
1117
1434
|
}
|
|
1118
1435
|
catch (err) {
|
|
1119
|
-
|
|
1436
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
1437
|
+
throw chain(err, "sys_mdb_update1", `Error updating ${collectionName}`, sev, "k2db.updateAll")
|
|
1438
|
+
.setSensitive({
|
|
1439
|
+
op: "updateMany",
|
|
1440
|
+
collection: collectionName,
|
|
1441
|
+
scope,
|
|
1442
|
+
criteriaShape: summariseValueShape(criteria),
|
|
1443
|
+
valuesShape: summariseValueShape(values),
|
|
1444
|
+
// criteria may contain selectors; treat as sensitive. Keep it compact.
|
|
1445
|
+
criteriaPreview: redactShallowSecrets(criteria),
|
|
1446
|
+
valuesPreview: redactShallowSecrets(values),
|
|
1447
|
+
mongo: normaliseMongoError(err),
|
|
1448
|
+
});
|
|
1120
1449
|
}
|
|
1121
1450
|
}
|
|
1122
1451
|
/**
|
|
@@ -1172,7 +1501,18 @@ export class K2DB {
|
|
|
1172
1501
|
return { updated: res.modifiedCount };
|
|
1173
1502
|
}
|
|
1174
1503
|
catch (err) {
|
|
1175
|
-
|
|
1504
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
1505
|
+
throw chain(err, "sys_mdb_update_error", `Error updating ${collectionName}`, sev, "k2db.update")
|
|
1506
|
+
.setSensitive({
|
|
1507
|
+
op: replace ? "replaceOne" : "updateOne",
|
|
1508
|
+
collection: collectionName,
|
|
1509
|
+
uuid: id,
|
|
1510
|
+
replace,
|
|
1511
|
+
scope,
|
|
1512
|
+
dataShape: summariseValueShape(data),
|
|
1513
|
+
dataPreview: redactShallowSecrets(data),
|
|
1514
|
+
mongo: normaliseMongoError(err),
|
|
1515
|
+
});
|
|
1176
1516
|
}
|
|
1177
1517
|
}
|
|
1178
1518
|
/**
|
|
@@ -1190,7 +1530,16 @@ export class K2DB {
|
|
|
1190
1530
|
return { deleted: result.updated };
|
|
1191
1531
|
}
|
|
1192
1532
|
catch (err) {
|
|
1193
|
-
|
|
1533
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
1534
|
+
throw chain(err, "sys_mdb_deleteall_update", `Error deleting from ${collectionName}`, sev, "k2db.deleteAll")
|
|
1535
|
+
.setSensitive({
|
|
1536
|
+
op: "softDeleteMany",
|
|
1537
|
+
collection: collectionName,
|
|
1538
|
+
scope,
|
|
1539
|
+
criteriaShape: summariseValueShape(criteria),
|
|
1540
|
+
criteriaPreview: redactShallowSecrets(criteria),
|
|
1541
|
+
mongo: normaliseMongoError(err),
|
|
1542
|
+
});
|
|
1194
1543
|
}
|
|
1195
1544
|
}
|
|
1196
1545
|
/**
|
|
@@ -1219,7 +1568,15 @@ export class K2DB {
|
|
|
1219
1568
|
}
|
|
1220
1569
|
}
|
|
1221
1570
|
catch (err) {
|
|
1222
|
-
|
|
1571
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
1572
|
+
throw chain(err, "sys_mdb_remove_upd", "Error removing object from collection", sev, "k2db.delete")
|
|
1573
|
+
.setSensitive({
|
|
1574
|
+
op: "softDeleteOne",
|
|
1575
|
+
collection: collectionName,
|
|
1576
|
+
uuid: id,
|
|
1577
|
+
scope,
|
|
1578
|
+
mongo: normaliseMongoError(err),
|
|
1579
|
+
});
|
|
1223
1580
|
}
|
|
1224
1581
|
}
|
|
1225
1582
|
/**
|
|
@@ -1231,18 +1588,38 @@ export class K2DB {
|
|
|
1231
1588
|
async purge(collectionName, id, scope) {
|
|
1232
1589
|
id = K2DB.normalizeId(id);
|
|
1233
1590
|
const collection = await this.getCollection(collectionName);
|
|
1591
|
+
let findFilter;
|
|
1592
|
+
let delFilter;
|
|
1234
1593
|
try {
|
|
1235
|
-
|
|
1594
|
+
findFilter = this.applyScopeToCriteria({ _uuid: id, _deleted: true }, scope);
|
|
1236
1595
|
const item = await this.runTimed("findOne", { collectionName, ...findFilter }, async () => await collection.findOne(findFilter));
|
|
1237
1596
|
if (!item) {
|
|
1238
1597
|
throw new K2Error(ServiceError.SYSTEM_ERROR, "Cannot purge item that is not deleted", "sys_mdb_gcol_pg2");
|
|
1239
1598
|
}
|
|
1240
|
-
|
|
1599
|
+
delFilter = this.applyScopeToCriteria({ _uuid: id }, scope);
|
|
1241
1600
|
await this.runTimed("deleteOne", { collectionName, ...delFilter }, async () => await collection.deleteOne(delFilter));
|
|
1242
1601
|
return { id };
|
|
1243
1602
|
}
|
|
1244
1603
|
catch (err) {
|
|
1245
|
-
|
|
1604
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
1605
|
+
const k2 = chain(err, "sys_mdb_pg", `Error purging item with id: ${id}`, sev, "k2db.purge")
|
|
1606
|
+
.setSensitive({
|
|
1607
|
+
op: "purge",
|
|
1608
|
+
collection: collectionName,
|
|
1609
|
+
uuid: id,
|
|
1610
|
+
scope,
|
|
1611
|
+
findFilterShape: summariseValueShape(findFilter),
|
|
1612
|
+
delFilterShape: summariseValueShape(delFilter),
|
|
1613
|
+
findFilterPreview: redactShallowSecrets(findFilter),
|
|
1614
|
+
delFilterPreview: redactShallowSecrets(delFilter),
|
|
1615
|
+
mongo: normaliseMongoError(err),
|
|
1616
|
+
});
|
|
1617
|
+
emitDbError(k2, {
|
|
1618
|
+
op: "purge",
|
|
1619
|
+
collection: collectionName,
|
|
1620
|
+
uuid: id,
|
|
1621
|
+
});
|
|
1622
|
+
throw k2;
|
|
1246
1623
|
}
|
|
1247
1624
|
}
|
|
1248
1625
|
/**
|
|
@@ -1302,9 +1679,10 @@ export class K2DB {
|
|
|
1302
1679
|
*/
|
|
1303
1680
|
async count(collectionName, criteria, scope) {
|
|
1304
1681
|
const collection = await this.getCollection(collectionName);
|
|
1682
|
+
let query;
|
|
1305
1683
|
try {
|
|
1306
1684
|
const norm = K2DB.normalizeCriteriaIds(criteria || {});
|
|
1307
|
-
|
|
1685
|
+
query = {
|
|
1308
1686
|
...norm,
|
|
1309
1687
|
...(criteria && Object.prototype.hasOwnProperty.call(criteria, "_deleted")
|
|
1310
1688
|
? {}
|
|
@@ -1315,7 +1693,21 @@ export class K2DB {
|
|
|
1315
1693
|
return { count: cnt };
|
|
1316
1694
|
}
|
|
1317
1695
|
catch (err) {
|
|
1318
|
-
|
|
1696
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
1697
|
+
const k2 = chain(err, "sys_mdb_cn", "Error counting objects with given criteria", sev, "k2db.count")
|
|
1698
|
+
.setSensitive({
|
|
1699
|
+
op: "countDocuments",
|
|
1700
|
+
collection: collectionName,
|
|
1701
|
+
scope,
|
|
1702
|
+
queryShape: summariseValueShape(query),
|
|
1703
|
+
queryPreview: redactShallowSecrets(query),
|
|
1704
|
+
mongo: normaliseMongoError(err),
|
|
1705
|
+
});
|
|
1706
|
+
emitDbError(k2, {
|
|
1707
|
+
op: "countDocuments",
|
|
1708
|
+
collection: collectionName,
|
|
1709
|
+
});
|
|
1710
|
+
throw k2;
|
|
1319
1711
|
}
|
|
1320
1712
|
}
|
|
1321
1713
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frogfish/k2db",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.4",
|
|
4
4
|
"description": "A data handling library for K2 applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "data.js",
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
"license": "GPL-3.0-only",
|
|
19
19
|
"author": "El'Diablo",
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@frogfish/k2error": "^3.0.
|
|
22
|
-
"@frogfish/ratatouille": "^0.
|
|
21
|
+
"@frogfish/k2error": "^3.0.2",
|
|
22
|
+
"@frogfish/ratatouille": "^1.0.0",
|
|
23
23
|
"debug": "^4.3.7",
|
|
24
24
|
"mongodb": "^6.9.0",
|
|
25
25
|
"uuid": "^10.0.0",
|