@frogfish/k2db 3.0.3 → 3.0.6
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/db.js +445 -49
- package/package.json +3 -3
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
|
/**
|
|
@@ -872,8 +1182,10 @@ export class K2DB {
|
|
|
872
1182
|
}
|
|
873
1183
|
else if (lu.localField && lu.foreignField) {
|
|
874
1184
|
// Convert simple lookup to pipeline lookup so we can enforce owner scope (and deleted) in foreign coll.
|
|
875
|
-
|
|
876
|
-
|
|
1185
|
+
// NOTE: MongoDB aggregation user variables must start with a letter.
|
|
1186
|
+
// Avoid leading underscores (e.g. "__lk") which fail to parse on MongoDB.
|
|
1187
|
+
const localVar = "k2lk";
|
|
1188
|
+
const ownerVar = "k2own";
|
|
877
1189
|
lu.let = { [localVar]: `$${lu.localField}`, [ownerVar]: normalizedScope };
|
|
878
1190
|
lu.pipeline = [
|
|
879
1191
|
{
|
|
@@ -966,7 +1278,9 @@ export class K2DB {
|
|
|
966
1278
|
}
|
|
967
1279
|
else if (lu.localField && lu.foreignField) {
|
|
968
1280
|
// Convert simple lookup to pipeline lookup to filter _deleted
|
|
969
|
-
|
|
1281
|
+
// NOTE: MongoDB aggregation user variables must start with a letter.
|
|
1282
|
+
// Avoid leading underscores (e.g. "__lk") which fail to parse on MongoDB.
|
|
1283
|
+
const localVar = "k2lk";
|
|
970
1284
|
lu.let = { [localVar]: `$${lu.localField}` };
|
|
971
1285
|
lu.pipeline = [
|
|
972
1286
|
{
|
|
@@ -1070,10 +1384,17 @@ export class K2DB {
|
|
|
1070
1384
|
if (err.code === 11000 && err.keyPattern && err.keyPattern._uuid) {
|
|
1071
1385
|
throw new K2Error(ServiceError.ALREADY_EXISTS, `A document with _uuid ${document._uuid} already exists.`, "sys_mdb_crv3");
|
|
1072
1386
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1387
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
1388
|
+
throw chain(err, "sys_mdb_sav", "Error saving object to database", sev, "k2db.create")
|
|
1389
|
+
.setSensitive({
|
|
1390
|
+
op: "insertOne",
|
|
1391
|
+
collection: collectionName,
|
|
1392
|
+
owner: normalizedOwner,
|
|
1393
|
+
uuid: document._uuid,
|
|
1394
|
+
documentShape: summariseValueShape(document),
|
|
1395
|
+
userFieldPreview: redactShallowSecrets(safeData),
|
|
1396
|
+
mongo: normaliseMongoError(err),
|
|
1397
|
+
});
|
|
1077
1398
|
}
|
|
1078
1399
|
}
|
|
1079
1400
|
/**
|
|
@@ -1116,7 +1437,19 @@ export class K2DB {
|
|
|
1116
1437
|
};
|
|
1117
1438
|
}
|
|
1118
1439
|
catch (err) {
|
|
1119
|
-
|
|
1440
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
1441
|
+
throw chain(err, "sys_mdb_update1", `Error updating ${collectionName}`, sev, "k2db.updateAll")
|
|
1442
|
+
.setSensitive({
|
|
1443
|
+
op: "updateMany",
|
|
1444
|
+
collection: collectionName,
|
|
1445
|
+
scope,
|
|
1446
|
+
criteriaShape: summariseValueShape(criteria),
|
|
1447
|
+
valuesShape: summariseValueShape(values),
|
|
1448
|
+
// criteria may contain selectors; treat as sensitive. Keep it compact.
|
|
1449
|
+
criteriaPreview: redactShallowSecrets(criteria),
|
|
1450
|
+
valuesPreview: redactShallowSecrets(values),
|
|
1451
|
+
mongo: normaliseMongoError(err),
|
|
1452
|
+
});
|
|
1120
1453
|
}
|
|
1121
1454
|
}
|
|
1122
1455
|
/**
|
|
@@ -1172,7 +1505,18 @@ export class K2DB {
|
|
|
1172
1505
|
return { updated: res.modifiedCount };
|
|
1173
1506
|
}
|
|
1174
1507
|
catch (err) {
|
|
1175
|
-
|
|
1508
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
1509
|
+
throw chain(err, "sys_mdb_update_error", `Error updating ${collectionName}`, sev, "k2db.update")
|
|
1510
|
+
.setSensitive({
|
|
1511
|
+
op: replace ? "replaceOne" : "updateOne",
|
|
1512
|
+
collection: collectionName,
|
|
1513
|
+
uuid: id,
|
|
1514
|
+
replace,
|
|
1515
|
+
scope,
|
|
1516
|
+
dataShape: summariseValueShape(data),
|
|
1517
|
+
dataPreview: redactShallowSecrets(data),
|
|
1518
|
+
mongo: normaliseMongoError(err),
|
|
1519
|
+
});
|
|
1176
1520
|
}
|
|
1177
1521
|
}
|
|
1178
1522
|
/**
|
|
@@ -1190,7 +1534,16 @@ export class K2DB {
|
|
|
1190
1534
|
return { deleted: result.updated };
|
|
1191
1535
|
}
|
|
1192
1536
|
catch (err) {
|
|
1193
|
-
|
|
1537
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
1538
|
+
throw chain(err, "sys_mdb_deleteall_update", `Error deleting from ${collectionName}`, sev, "k2db.deleteAll")
|
|
1539
|
+
.setSensitive({
|
|
1540
|
+
op: "softDeleteMany",
|
|
1541
|
+
collection: collectionName,
|
|
1542
|
+
scope,
|
|
1543
|
+
criteriaShape: summariseValueShape(criteria),
|
|
1544
|
+
criteriaPreview: redactShallowSecrets(criteria),
|
|
1545
|
+
mongo: normaliseMongoError(err),
|
|
1546
|
+
});
|
|
1194
1547
|
}
|
|
1195
1548
|
}
|
|
1196
1549
|
/**
|
|
@@ -1219,7 +1572,15 @@ export class K2DB {
|
|
|
1219
1572
|
}
|
|
1220
1573
|
}
|
|
1221
1574
|
catch (err) {
|
|
1222
|
-
|
|
1575
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
1576
|
+
throw chain(err, "sys_mdb_remove_upd", "Error removing object from collection", sev, "k2db.delete")
|
|
1577
|
+
.setSensitive({
|
|
1578
|
+
op: "softDeleteOne",
|
|
1579
|
+
collection: collectionName,
|
|
1580
|
+
uuid: id,
|
|
1581
|
+
scope,
|
|
1582
|
+
mongo: normaliseMongoError(err),
|
|
1583
|
+
});
|
|
1223
1584
|
}
|
|
1224
1585
|
}
|
|
1225
1586
|
/**
|
|
@@ -1231,18 +1592,38 @@ export class K2DB {
|
|
|
1231
1592
|
async purge(collectionName, id, scope) {
|
|
1232
1593
|
id = K2DB.normalizeId(id);
|
|
1233
1594
|
const collection = await this.getCollection(collectionName);
|
|
1595
|
+
let findFilter;
|
|
1596
|
+
let delFilter;
|
|
1234
1597
|
try {
|
|
1235
|
-
|
|
1598
|
+
findFilter = this.applyScopeToCriteria({ _uuid: id, _deleted: true }, scope);
|
|
1236
1599
|
const item = await this.runTimed("findOne", { collectionName, ...findFilter }, async () => await collection.findOne(findFilter));
|
|
1237
1600
|
if (!item) {
|
|
1238
1601
|
throw new K2Error(ServiceError.SYSTEM_ERROR, "Cannot purge item that is not deleted", "sys_mdb_gcol_pg2");
|
|
1239
1602
|
}
|
|
1240
|
-
|
|
1603
|
+
delFilter = this.applyScopeToCriteria({ _uuid: id }, scope);
|
|
1241
1604
|
await this.runTimed("deleteOne", { collectionName, ...delFilter }, async () => await collection.deleteOne(delFilter));
|
|
1242
1605
|
return { id };
|
|
1243
1606
|
}
|
|
1244
1607
|
catch (err) {
|
|
1245
|
-
|
|
1608
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
1609
|
+
const k2 = chain(err, "sys_mdb_pg", `Error purging item with id: ${id}`, sev, "k2db.purge")
|
|
1610
|
+
.setSensitive({
|
|
1611
|
+
op: "purge",
|
|
1612
|
+
collection: collectionName,
|
|
1613
|
+
uuid: id,
|
|
1614
|
+
scope,
|
|
1615
|
+
findFilterShape: summariseValueShape(findFilter),
|
|
1616
|
+
delFilterShape: summariseValueShape(delFilter),
|
|
1617
|
+
findFilterPreview: redactShallowSecrets(findFilter),
|
|
1618
|
+
delFilterPreview: redactShallowSecrets(delFilter),
|
|
1619
|
+
mongo: normaliseMongoError(err),
|
|
1620
|
+
});
|
|
1621
|
+
emitDbError(k2, {
|
|
1622
|
+
op: "purge",
|
|
1623
|
+
collection: collectionName,
|
|
1624
|
+
uuid: id,
|
|
1625
|
+
});
|
|
1626
|
+
throw k2;
|
|
1246
1627
|
}
|
|
1247
1628
|
}
|
|
1248
1629
|
/**
|
|
@@ -1302,9 +1683,10 @@ export class K2DB {
|
|
|
1302
1683
|
*/
|
|
1303
1684
|
async count(collectionName, criteria, scope) {
|
|
1304
1685
|
const collection = await this.getCollection(collectionName);
|
|
1686
|
+
let query;
|
|
1305
1687
|
try {
|
|
1306
1688
|
const norm = K2DB.normalizeCriteriaIds(criteria || {});
|
|
1307
|
-
|
|
1689
|
+
query = {
|
|
1308
1690
|
...norm,
|
|
1309
1691
|
...(criteria && Object.prototype.hasOwnProperty.call(criteria, "_deleted")
|
|
1310
1692
|
? {}
|
|
@@ -1315,7 +1697,21 @@ export class K2DB {
|
|
|
1315
1697
|
return { count: cnt };
|
|
1316
1698
|
}
|
|
1317
1699
|
catch (err) {
|
|
1318
|
-
|
|
1700
|
+
const sev = err instanceof K2Error ? undefined : ServiceError.SYSTEM_ERROR;
|
|
1701
|
+
const k2 = chain(err, "sys_mdb_cn", "Error counting objects with given criteria", sev, "k2db.count")
|
|
1702
|
+
.setSensitive({
|
|
1703
|
+
op: "countDocuments",
|
|
1704
|
+
collection: collectionName,
|
|
1705
|
+
scope,
|
|
1706
|
+
queryShape: summariseValueShape(query),
|
|
1707
|
+
queryPreview: redactShallowSecrets(query),
|
|
1708
|
+
mongo: normaliseMongoError(err),
|
|
1709
|
+
});
|
|
1710
|
+
emitDbError(k2, {
|
|
1711
|
+
op: "countDocuments",
|
|
1712
|
+
collection: collectionName,
|
|
1713
|
+
});
|
|
1714
|
+
throw k2;
|
|
1319
1715
|
}
|
|
1320
1716
|
}
|
|
1321
1717
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frogfish/k2db",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.6",
|
|
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",
|