@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.
Files changed (4) hide show
  1. package/data.d.ts +28 -5
  2. package/data.js +45 -15
  3. package/db.js +438 -46
  4. 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
- id: string;
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
- throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_gc", `Error getting collection: ${collectionName}`);
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
- throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_fo", "Error finding document");
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
- throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_find_error", "Error executing find query");
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
- 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
- });
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
- throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_ag", "Aggregation failed");
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
- // 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");
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
- throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_update1", `Error updating ${collectionName}`);
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
- throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_update_error", `Error updating ${collectionName}`);
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
- throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_deleteall_update", `Error updating ${collectionName}`);
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
- throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_remove_upd", "Error removing object from collection");
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
- const findFilter = this.applyScopeToCriteria({ _uuid: id, _deleted: true }, scope);
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
- const delFilter = this.applyScopeToCriteria({ _uuid: id }, scope);
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
- throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_pg", `Error purging item with id: ${id}`);
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
- let query = {
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
- throw wrap(err, ServiceError.SYSTEM_ERROR, "sys_mdb_cn", "Error counting objects with given criteria");
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.2",
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.1",
22
- "@frogfish/ratatouille": "^0.1.7",
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",