@ignex/ninox 0.1.6 → 0.1.7

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/dist/index.d.ts CHANGED
@@ -2,7 +2,6 @@ import * as mongodb from 'mongodb';
2
2
  import { Db, MongoClient, ClientSession, Document, Filter, Hint, CollationOptions, ReadPreferenceLike, ReadConcern, WriteConcern, WithId, Condition, RootFilterOperators, Collection, FindOptions, Sort, FindCursor, ObjectId, Decimal128, ReadConcernLike, AggregateOptions, AggregationCursor, CreateCollectionOptions, TransactionOptions, InsertOneOptions, InsertOneResult, InsertManyResult, UpdateOptions, UpdateResult, FindOneAndUpdateOptions, DeleteResult, ChangeStreamOptions, ChangeStream } from 'mongodb';
3
3
  import { L as LoggerLike } from './timeout-B10CTiGm.js';
4
4
  export { C as CacheOptions, a as LRU, b as LRUOptions, c as LogFn, d as LogLevel, e as createCachedAsyncFactory, f as createCachedFactory, g as createConsoleLogger, h as createNoopLogger, i as hashString, s as sleep, j as sleepJittered, k as stableHash, l as stableStringify, w as withTimeout } from './timeout-B10CTiGm.js';
5
- import * as bson from 'bson';
6
5
 
7
6
  /**
8
7
  * Types + constants for the HotCache layer. All type-only (plus the stream-key
@@ -411,14 +410,6 @@ declare const createMongoCapabilitiesStore: () => MongoCapabilitiesStore;
411
410
  /** Probe a connected client/db via `hello`. */
412
411
  declare const probeMongoCapabilities: (client: MongoClient | Db | null | undefined) => Promise<MongoCapabilities>;
413
412
 
414
- /**
415
- * The ORM's error classes (`AppError` → `DomainError` / `InfraError` /
416
- * `BadRequest`), their type guards, and the `ERROR_HTTP_STATUS` table that sets
417
- * each error's `statusCode` at construction.
418
- *
419
- * A leaf module: nothing else in `src/errors/` imports this (so `driver-map.ts`
420
- * and `http-status.ts` can depend on it without cycles).
421
- */
422
413
  interface ErrorContext {
423
414
  db?: string;
424
415
  collection?: string;
@@ -437,23 +428,47 @@ declare class AppError extends Error {
437
428
  readonly code: string;
438
429
  readonly statusCode: number;
439
430
  readonly extra?: Record<string, unknown>;
440
- constructor(code: string, message: string, statusCode?: number, extra?: Record<string, unknown>);
441
431
  /**
442
- * Stable, client-safe serialization for framework error middleware: a
443
- * framework can forward `JSON.stringify(error)` (or `error.toJSON()`) straight
444
- * to the client — no parsing of server internals. `stack` is dropped; the
445
- * shape is always `{ name, code, message, statusCode, ...(extra) }`.
432
+ * Whether `message`/`extra` may be sent to a CLIENT.
433
+ *
434
+ * Fail-closed: a 4xx message is the caller's contract, a 5xx message is an
435
+ * operator detail that may quote a driver, a host or a query. Pass `expose`
436
+ * explicitly to override — only for a 5xx whose text was written for callers
437
+ * (e.g. `new InfraError("DRAINING", "queue is draining", extra, true)`).
438
+ */
439
+ readonly expose: boolean;
440
+ constructor(code: string, message: string, statusCode?: number, extra?: Record<string, unknown>, expose?: boolean);
441
+ /**
442
+ * Full, stack-free shape for LOGS and typed consumers:
443
+ * `{ name, code, message, statusCode, ...(extra) }`.
444
+ *
445
+ * It carries the real message, so it is NOT the client path — use
446
+ * {@link toClientPayload} (or `serializeError`) for anything a caller sees, and
447
+ * prefer `logger.error({ err })` so pino's own serializer keeps the stack in the
448
+ * log rather than in a payload.
446
449
  */
447
450
  toJSON(): Record<string, unknown>;
451
+ /**
452
+ * The client-safe payload — the same shape as {@link toJSON}, except that a
453
+ * non-exposed error's `message` is replaced by the canonical reason phrase and
454
+ * its `extra` is dropped. A framework can forward this (or `serializeError`'s
455
+ * result) without auditing what the message might contain.
456
+ *
457
+ * @param options - `expose: true` forces the real (masked) message + extra.
458
+ * @returns The client payload.
459
+ */
460
+ toClientPayload(options?: {
461
+ expose?: boolean;
462
+ }): Record<string, unknown>;
448
463
  }
449
464
  declare class DomainError extends AppError {
450
- constructor(code: string, message: string, extra?: Record<string, unknown>);
465
+ constructor(code: string, message: string, extra?: Record<string, unknown>, expose?: boolean);
451
466
  }
452
467
  declare class InfraError extends AppError {
453
- constructor(code: string, message: string, extra?: Record<string, unknown>);
468
+ constructor(code: string, message: string, extra?: Record<string, unknown>, expose?: boolean);
454
469
  }
455
470
  declare class BadRequest extends DomainError {
456
- constructor(message: string, extra?: Record<string, unknown>);
471
+ constructor(message: string, extra?: Record<string, unknown>, expose?: boolean);
457
472
  }
458
473
  declare const isAppError: (err: unknown) => err is AppError;
459
474
  declare const isDomainError: (err: unknown) => err is DomainError;
@@ -483,8 +498,16 @@ declare const isMappedMongoError: (error: unknown) => error is AppError;
483
498
 
484
499
  /** Effective HTTP status for any thrown value: AppError-aware, raw → 500 (503 transient). */
485
500
  declare const httpStatusForError: (err: unknown) => number;
501
+ /** Options for {@link serializeError}. */
502
+ interface SerializeErrorOptions {
503
+ /**
504
+ * Include the real message (and `extra`) even for a non-exposed error — the
505
+ * development / trusted-caller path. It is still credential-masked.
506
+ */
507
+ expose?: boolean;
508
+ }
486
509
  /**
487
- * Reduce any thrown value to a stable, client-safe error payload:
510
+ * Reduce any thrown value to a stable, CLIENT-SAFE error payload:
488
511
  * `{ name, code, message, statusCode, ...(extra) }`.
489
512
  *
490
513
  * Raw driver errors are mapped through `mapMongoDriverError` first, so a
@@ -495,8 +518,15 @@ declare const httpStatusForError: (err: unknown) => number;
495
518
  * const payload = serializeError(err, { db, collection, op });
496
519
  * res.status(payload.statusCode).json(payload);
497
520
  * });
521
+ *
522
+ * Fail-closed by default: a 4xx message/extra is the caller's contract, while a
523
+ * 5xx message is replaced by the canonical reason phrase (`Internal Server
524
+ * Error`, `Service Unavailable`, …) and `extra` is dropped — a driver message
525
+ * that quotes a host, a query or a stored value never reaches a client. The
526
+ * detail stays on the error itself for logs; pass `{ expose: true }` (or a
527
+ * per-error `expose`) to opt out for a trusted caller.
498
528
  */
499
- declare const serializeError: (err: unknown, ctx?: ErrorContext) => Record<string, unknown>;
529
+ declare const serializeError: (err: unknown, ctx?: ErrorContext, options?: SerializeErrorOptions) => Record<string, unknown>;
500
530
 
501
531
  /**
502
532
  * Transient-error detection: which MongoDB errors are safe to retry. This is a
@@ -2038,7 +2068,7 @@ interface CrudOpsOptions {
2038
2068
  * and fire lifecycle hooks.
2039
2069
  */
2040
2070
  declare const makeCrudOps: <TClients extends DbClientsDefinition, TDb extends ExtractDbNames<TClients>>(client: Db, dbLabel: string, logger: LoggerLike, opts: CrudOpsOptions) => {
2041
- watchCollection: <X extends ColNames<TClients, TDb>>(collection: X, pipeline?: bson.Document[], options?: mongodb.ChangeStreamOptions) => mongodb.ChangeStream<CollectionDoc<TClients[TDb]["collections"][X]>, mongodb.ChangeStreamDocument<CollectionDoc<TClients[TDb]["collections"][X]>>>;
2071
+ watchCollection: <X extends ColNames<TClients, TDb>>(collection: X, pipeline?: mongodb.Document[], options?: mongodb.ChangeStreamOptions) => mongodb.ChangeStream<CollectionDoc<TClients[TDb]["collections"][X]>, mongodb.ChangeStreamDocument<CollectionDoc<TClients[TDb]["collections"][X]>>>;
2042
2072
  query: <X extends ColNames<TClients, TDb>>(collection: X) => QueryBuilder<CollectionDoc<TClients[TDb]["collections"][X]>, CollectionDoc<TClients[TDb]["collections"][X]>>;
2043
2073
  insertOne: <X extends ColNames<TClients, TDb>>(collection: X, doc: InsertInput<CollectionDoc<TClients[TDb]["collections"][X]>>, options?: mongodb.InsertOneOptions & QueryOptions) => Promise<mongodb.InsertOneResult<CollectionDoc<TClients[TDb]["collections"][X]>>>;
2044
2074
  insertMany: <X extends ColNames<TClients, TDb>>(collection: X, docs: readonly InsertInput<CollectionDoc<TClients[TDb]["collections"][X]>>[], options?: mongodb.BulkWriteOptions & QueryOptions) => Promise<mongodb.InsertManyResult<CollectionDoc<TClients[TDb]["collections"][X]>>>;
@@ -2054,8 +2084,8 @@ declare const makeCrudOps: <TClients extends DbClientsDefinition, TDb extends Ex
2054
2084
  filter: FilterInput<CollectionDoc<TClients[TDb]["collections"][X]>>;
2055
2085
  update: UpdateInput<CollectionDoc<TClients[TDb]["collections"][X]>>;
2056
2086
  upsert?: boolean;
2057
- }[], options?: mongodb.BulkWriteOptions & QueryOptions) => Promise<bson.Document>;
2058
- bulkWrite: <X extends ColNames<TClients, TDb>>(collection: X, operations: mongodb.AnyBulkWriteOperation<CollectionDoc<TClients[TDb]["collections"][X]>>[], options?: mongodb.BulkWriteOptions & QueryOptions) => Promise<bson.Document>;
2087
+ }[], options?: mongodb.BulkWriteOptions & QueryOptions) => Promise<mongodb.Document>;
2088
+ bulkWrite: <X extends ColNames<TClients, TDb>>(collection: X, operations: mongodb.AnyBulkWriteOperation<CollectionDoc<TClients[TDb]["collections"][X]>>[], options?: mongodb.BulkWriteOptions & QueryOptions) => Promise<mongodb.Document>;
2059
2089
  updateWithVersion: <X extends ColNames<TClients, TDb>>(collection: X, filter: FilterInput<CollectionDoc<TClients[TDb]["collections"][X]>>, update: UpdateInput<CollectionDoc<TClients[TDb]["collections"][X]>>, options?: mongodb.FindOneAndUpdateOptions & QueryOptions) => Promise<VersionedUpdateResult<CollectionDoc<TClients[TDb]["collections"][X]>>>;
2060
2090
  findOneAndDelete: <X extends ColNames<TClients, TDb>>(collection: X, filter: FilterInput<CollectionDoc<TClients[TDb]["collections"][X]>>, options?: mongodb.FindOneAndDeleteOptions & QueryOptions) => Promise<CollectionDoc<TClients[TDb]["collections"][X]> | null>;
2061
2091
  replaceOne: <X extends ColNames<TClients, TDb>>(collection: X, filter: FilterInput<CollectionDoc<TClients[TDb]["collections"][X]>>, replacement: InsertInput<CollectionDoc<TClients[TDb]["collections"][X]>>, options?: mongodb.ReplaceOptions & QueryOptions) => Promise<mongodb.UpdateResult<CollectionDoc<TClients[TDb]["collections"][X]>>>;
package/dist/index.js CHANGED
@@ -57,6 +57,17 @@ var probeMongoCapabilities = async (client) => {
57
57
  }
58
58
  };
59
59
 
60
+ // src/utils/redact.ts
61
+ var CONNECTION_CREDENTIALS = /([a-z][a-z0-9+.-]*:\/\/[^/\s:@]*):[^/\s@]*@/gi;
62
+ var SECRET_PAIR = /\b(password|passwd|pwd|secret|token|api[-_]?key|access[-_]?key)=([^&\s;,"')]+)/gi;
63
+ var maskCredentials = (value) => value.replace(CONNECTION_CREDENTIALS, "$1:***@");
64
+ var maskSecretPairs = (value) => value.replace(SECRET_PAIR, "$1=***");
65
+ var redactText = (value, max = 300) => {
66
+ const single = maskSecretPairs(maskCredentials(value)).split("\n").find((line) => line.trim().length > 0);
67
+ const text = (single ?? "").trim();
68
+ return text.length <= max ? text : `${text.slice(0, max - 1)}\u2026`;
69
+ };
70
+
60
71
  // src/errors/classes.ts
61
72
  var ERROR_HTTP_STATUS = {
62
73
  BAD_REQUEST: 400,
@@ -68,22 +79,49 @@ var ERROR_HTTP_STATUS = {
68
79
  VALIDATION_FAILED: 422,
69
80
  MONGO_TIMEOUT: 504
70
81
  };
82
+ var STATUS_MESSAGES = {
83
+ 400: "Bad Request",
84
+ 401: "Unauthorized",
85
+ 403: "Forbidden",
86
+ 404: "Not Found",
87
+ 409: "Conflict",
88
+ 422: "Unprocessable Entity",
89
+ 429: "Too Many Requests",
90
+ 500: "Internal Server Error",
91
+ 502: "Bad Gateway",
92
+ 503: "Service Unavailable",
93
+ 504: "Gateway Timeout"
94
+ };
95
+ var genericStatusMessage = (statusCode) => STATUS_MESSAGES[statusCode] ?? (statusCode >= 500 ? "Internal Server Error" : "Request failed");
71
96
  var AppError = class extends Error {
72
97
  code;
73
98
  statusCode;
74
99
  extra;
75
- constructor(code, message, statusCode = 500, extra) {
100
+ /**
101
+ * Whether `message`/`extra` may be sent to a CLIENT.
102
+ *
103
+ * Fail-closed: a 4xx message is the caller's contract, a 5xx message is an
104
+ * operator detail that may quote a driver, a host or a query. Pass `expose`
105
+ * explicitly to override — only for a 5xx whose text was written for callers
106
+ * (e.g. `new InfraError("DRAINING", "queue is draining", extra, true)`).
107
+ */
108
+ expose;
109
+ constructor(code, message, statusCode = 500, extra, expose) {
76
110
  super(message);
77
111
  this.name = "AppError";
78
112
  this.code = code;
79
113
  this.statusCode = statusCode;
114
+ this.expose = expose ?? statusCode < 500;
80
115
  if (extra !== void 0) this.extra = extra;
81
116
  }
82
117
  /**
83
- * Stable, client-safe serialization for framework error middleware: a
84
- * framework can forward `JSON.stringify(error)` (or `error.toJSON()`) straight
85
- * to the client — no parsing of server internals. `stack` is dropped; the
86
- * shape is always `{ name, code, message, statusCode, ...(extra) }`.
118
+ * Full, stack-free shape for LOGS and typed consumers:
119
+ * `{ name, code, message, statusCode, ...(extra) }`.
120
+ *
121
+ * It carries the real message, so it is NOT the client path — use
122
+ * {@link toClientPayload} (or `serializeError`) for anything a caller sees, and
123
+ * prefer `logger.error({ err })` so pino's own serializer keeps the stack in the
124
+ * log rather than in a payload.
87
125
  */
88
126
  toJSON() {
89
127
  return {
@@ -94,22 +132,42 @@ var AppError = class extends Error {
94
132
  ...this.extra !== void 0 ? { extra: this.extra } : {}
95
133
  };
96
134
  }
135
+ /**
136
+ * The client-safe payload — the same shape as {@link toJSON}, except that a
137
+ * non-exposed error's `message` is replaced by the canonical reason phrase and
138
+ * its `extra` is dropped. A framework can forward this (or `serializeError`'s
139
+ * result) without auditing what the message might contain.
140
+ *
141
+ * @param options - `expose: true` forces the real (masked) message + extra.
142
+ * @returns The client payload.
143
+ */
144
+ toClientPayload(options = {}) {
145
+ const expose = options.expose === true || this.expose;
146
+ const payload = {
147
+ name: this.name,
148
+ code: this.code,
149
+ message: expose ? maskCredentials(this.message) : genericStatusMessage(this.statusCode),
150
+ statusCode: this.statusCode
151
+ };
152
+ if (expose && this.extra !== void 0) payload.extra = this.extra;
153
+ return payload;
154
+ }
97
155
  };
98
156
  var DomainError = class extends AppError {
99
- constructor(code, message, extra) {
100
- super(code, message, ERROR_HTTP_STATUS[code] ?? 400, extra);
157
+ constructor(code, message, extra, expose) {
158
+ super(code, message, ERROR_HTTP_STATUS[code] ?? 400, extra, expose);
101
159
  this.name = "DomainError";
102
160
  }
103
161
  };
104
162
  var InfraError = class extends AppError {
105
- constructor(code, message, extra) {
106
- super(code, message, ERROR_HTTP_STATUS[code] ?? 500, extra);
163
+ constructor(code, message, extra, expose) {
164
+ super(code, message, ERROR_HTTP_STATUS[code] ?? 500, extra, expose);
107
165
  this.name = "InfraError";
108
166
  }
109
167
  };
110
168
  var BadRequest = class extends DomainError {
111
- constructor(message, extra) {
112
- super("BAD_REQUEST", message, extra);
169
+ constructor(message, extra, expose) {
170
+ super("BAD_REQUEST", message, extra, expose);
113
171
  this.name = "BadRequest";
114
172
  }
115
173
  };
@@ -279,13 +337,20 @@ var httpStatusForError = (err) => {
279
337
  if (isMongoTransientError(err)) return 503;
280
338
  return 500;
281
339
  };
282
- var serializeError = (err, ctx = {}) => {
340
+ var serializeError = (err, ctx = {}, options = {}) => {
283
341
  const mapped = mapMongoDriverError(err, ctx);
284
- if (mapped instanceof AppError) return mapped.toJSON();
285
- if (mapped instanceof Error) {
286
- return { name: mapped.name, code: "UNKNOWN", message: mapped.message, statusCode: 500 };
342
+ if (mapped instanceof AppError) return mapped.toClientPayload(options);
343
+ const statusCode = httpStatusForError(mapped);
344
+ const name = mapped instanceof Error ? mapped.name : "UnknownError";
345
+ if (options.expose !== true) {
346
+ return { name, code: "UNKNOWN", message: genericStatusMessage(statusCode), statusCode };
287
347
  }
288
- return { name: "UnknownError", code: "UNKNOWN", message: String(mapped), statusCode: 500 };
348
+ return {
349
+ name,
350
+ code: "UNKNOWN",
351
+ message: redactText(mapped instanceof Error ? mapped.message : String(mapped)),
352
+ statusCode
353
+ };
289
354
  };
290
355
  var cloneDeep = (value) => {
291
356
  if (value instanceof Date) return new Date(value.getTime());
@@ -525,6 +590,7 @@ var RefreshTicker = class {
525
590
  this.host = host;
526
591
  this.intervalMs = tickIntervalMs;
527
592
  }
593
+ host;
528
594
  timer;
529
595
  intervalMs;
530
596
  /** Whether the timer is currently running. */
@@ -618,16 +684,26 @@ var normalizeWatchId = (id) => {
618
684
  };
619
685
 
620
686
  // src/utils/watch-errors.ts
621
- var isPermanentWatchError = (message) => (
622
- // Standalone server: `$changeStream` is not supported at all.
623
- /only supported on replica sets/i.test(message) || // Credentials/authorization problems: the user cannot run `aggregate` on
624
- // this collection/server. Wrong creds, missing roles, or a user created
625
- // without the `read`/`changeStream` privilege will never start working by
626
- // retrying — fail once and stop instead of retrying forever.
627
- /requires authentication|not authorized|unauthorized|authentication failed|Command .* requires authentication/i.test(
628
- message
629
- )
630
- );
687
+ var STANDALONE = /only supported on replica sets|not supported on (?:a )?standalone/i;
688
+ var CREDENTIALS = /requires authentication|not authorized|unauthorized|authentication failed|Command .* requires authentication/i;
689
+ var explainPermanentWatchError = (message) => {
690
+ if (STANDALONE.test(message)) {
691
+ return {
692
+ reason: "standalone",
693
+ headline: "change streams need a replica set",
694
+ hint: "Point the connection at a replica set (a single-node replica set is fine for local dev), or set cacheWatch: false / cache: { ttlMs } to run write-through only."
695
+ };
696
+ }
697
+ if (CREDENTIALS.test(message)) {
698
+ return {
699
+ reason: "credentials",
700
+ headline: "the connection is not authorized for change streams",
701
+ hint: "Change streams need an authenticated user with the read + changeStream privilege (usually granted on the authSource database) \u2014 fix the credentials in .env, then restart."
702
+ };
703
+ }
704
+ return void 0;
705
+ };
706
+ var isPermanentWatchError = (message) => explainPermanentWatchError(message) !== void 0;
631
707
 
632
708
  // src/cache/hot-cache/watcher.ts
633
709
  var resolveDbSafe = (ref, logger) => {
@@ -649,6 +725,7 @@ var WatchCoordinator = class {
649
725
  constructor(host) {
650
726
  this.host = host;
651
727
  }
728
+ host;
652
729
  streams = /* @__PURE__ */ new Map();
653
730
  failedStreams = /* @__PURE__ */ new Set();
654
731
  stopped = true;
@@ -762,7 +839,7 @@ var WatchCoordinator = class {
762
839
  }
763
840
  }
764
841
  if (isPermanentWatchError(message)) {
765
- this.markStreamFailed(key);
842
+ this.markStreamFailed(key, explainPermanentWatchError(message));
766
843
  return;
767
844
  }
768
845
  }
@@ -771,12 +848,15 @@ var WatchCoordinator = class {
771
848
  await sleepJittered(backoff, 1e3, true);
772
849
  }
773
850
  }
774
- markStreamFailed(key) {
851
+ markStreamFailed(key, verdict) {
775
852
  this.failedStreams.add(key);
776
853
  if (!this.host.isReplica()) return;
777
854
  const planned = this.plannedStreamKeys();
778
855
  if (planned.size > 0 && [...planned].every((k) => this.failedStreams.has(k))) {
779
- this.host.logger.warn?.({}, "hot cache: change streams unsupported, falling back to ticker");
856
+ this.host.logger.warn?.(
857
+ verdict === void 0 ? {} : { reason: verdict.reason, hint: verdict.hint },
858
+ `hot cache: change streams unsupported${verdict === void 0 ? "" : ` \u2014 ${verdict.headline}`}, falling back to ticker`
859
+ );
780
860
  this.host.fallbackToStandalone();
781
861
  }
782
862
  }
@@ -1787,6 +1867,7 @@ var QueryBuilder = class _QueryBuilder {
1787
1867
  constructor(ctx) {
1788
1868
  this.ctx = ctx;
1789
1869
  }
1870
+ ctx;
1790
1871
  _filter = {};
1791
1872
  _sort;
1792
1873
  _skip;
@@ -2814,13 +2895,19 @@ var CacheInvalidator = class {
2814
2895
  } catch {
2815
2896
  }
2816
2897
  }
2817
- if (isPermanentWatchError(message)) {
2898
+ const verdict = explainPermanentWatchError(message);
2899
+ if (verdict) {
2818
2900
  this.disabled = true;
2819
2901
  if (!this.warnedUnsupported) {
2820
2902
  this.warnedUnsupported = true;
2821
2903
  this.logger.warn?.(
2822
- { collection: ref.collection, error: message },
2823
- "cacheWatch: change streams unavailable permanently \u2014 the query cache stays write-through only; set cache: { ttlMs } or disable cacheWatch"
2904
+ {
2905
+ collection: ref.collection,
2906
+ reason: verdict.reason,
2907
+ error: message,
2908
+ hint: verdict.hint
2909
+ },
2910
+ `cacheWatch: disabled \u2014 ${verdict.headline}; the query cache stays write-through only`
2824
2911
  );
2825
2912
  }
2826
2913
  return;
@@ -3248,6 +3335,8 @@ var PipelineBuilder = class _PipelineBuilder {
3248
3335
  this.deps = deps2;
3249
3336
  this.stages = stages;
3250
3337
  }
3338
+ deps;
3339
+ stages;
3251
3340
  /** Raw accumulated stages — embeddable into `$lookup` / `$facet` branches. */
3252
3341
  raw() {
3253
3342
  return this.stages;