@ignex/ninox 0.1.6 → 0.2.0

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.js CHANGED
@@ -1,7 +1,9 @@
1
- import { ObjectId, Decimal128, Long, Int32, Double, Timestamp, Binary, MinKey, MaxKey, DBRef, Code, MongoClient } from 'mongodb';
1
+ import { BSON, ObjectId, Decimal128, Long, Int32, Double, Timestamp, Binary, MinKey, MaxKey, DBRef, Code, MongoClient } from 'mongodb';
2
+ import { createHash, randomUUID } from 'crypto';
2
3
  import { writeFile, mkdir, readdir } from 'fs/promises';
3
4
  import { basename, join } from 'path';
4
5
  import { pathToFileURL } from 'url';
6
+ import { isIP } from 'net';
5
7
 
6
8
  // src/utils/timeout.ts
7
9
  var withTimeout = (promise, timeoutMs, label) => {
@@ -57,6 +59,17 @@ var probeMongoCapabilities = async (client) => {
57
59
  }
58
60
  };
59
61
 
62
+ // src/utils/redact.ts
63
+ var CONNECTION_CREDENTIALS = /([a-z][a-z0-9+.-]*:\/\/[^/\s:@]*):[^/\s@]*@/gi;
64
+ var SECRET_PAIR = /\b(password|passwd|pwd|secret|token|api[-_]?key|access[-_]?key)=([^&\s;,"')]+)/gi;
65
+ var maskCredentials = (value) => value.replace(CONNECTION_CREDENTIALS, "$1:***@");
66
+ var maskSecretPairs = (value) => value.replace(SECRET_PAIR, "$1=***");
67
+ var redactText = (value, max = 300) => {
68
+ const single = maskSecretPairs(maskCredentials(value)).split("\n").find((line) => line.trim().length > 0);
69
+ const text = (single ?? "").trim();
70
+ return text.length <= max ? text : `${text.slice(0, max - 1)}\u2026`;
71
+ };
72
+
60
73
  // src/errors/classes.ts
61
74
  var ERROR_HTTP_STATUS = {
62
75
  BAD_REQUEST: 400,
@@ -68,22 +81,49 @@ var ERROR_HTTP_STATUS = {
68
81
  VALIDATION_FAILED: 422,
69
82
  MONGO_TIMEOUT: 504
70
83
  };
84
+ var STATUS_MESSAGES = {
85
+ 400: "Bad Request",
86
+ 401: "Unauthorized",
87
+ 403: "Forbidden",
88
+ 404: "Not Found",
89
+ 409: "Conflict",
90
+ 422: "Unprocessable Entity",
91
+ 429: "Too Many Requests",
92
+ 500: "Internal Server Error",
93
+ 502: "Bad Gateway",
94
+ 503: "Service Unavailable",
95
+ 504: "Gateway Timeout"
96
+ };
97
+ var genericStatusMessage = (statusCode) => STATUS_MESSAGES[statusCode] ?? (statusCode >= 500 ? "Internal Server Error" : "Request failed");
71
98
  var AppError = class extends Error {
72
99
  code;
73
100
  statusCode;
74
101
  extra;
75
- constructor(code, message, statusCode = 500, extra) {
102
+ /**
103
+ * Whether `message`/`extra` may be sent to a CLIENT.
104
+ *
105
+ * Fail-closed: a 4xx message is the caller's contract, a 5xx message is an
106
+ * operator detail that may quote a driver, a host or a query. Pass `expose`
107
+ * explicitly to override — only for a 5xx whose text was written for callers
108
+ * (e.g. `new InfraError("DRAINING", "queue is draining", extra, true)`).
109
+ */
110
+ expose;
111
+ constructor(code, message, statusCode = 500, extra, expose) {
76
112
  super(message);
77
113
  this.name = "AppError";
78
114
  this.code = code;
79
115
  this.statusCode = statusCode;
116
+ this.expose = expose ?? statusCode < 500;
80
117
  if (extra !== void 0) this.extra = extra;
81
118
  }
82
119
  /**
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) }`.
120
+ * Full, stack-free shape for LOGS and typed consumers:
121
+ * `{ name, code, message, statusCode, ...(extra) }`.
122
+ *
123
+ * It carries the real message, so it is NOT the client path — use
124
+ * {@link toClientPayload} (or `serializeError`) for anything a caller sees, and
125
+ * prefer `logger.error({ err })` so pino's own serializer keeps the stack in the
126
+ * log rather than in a payload.
87
127
  */
88
128
  toJSON() {
89
129
  return {
@@ -94,22 +134,42 @@ var AppError = class extends Error {
94
134
  ...this.extra !== void 0 ? { extra: this.extra } : {}
95
135
  };
96
136
  }
137
+ /**
138
+ * The client-safe payload — the same shape as {@link toJSON}, except that a
139
+ * non-exposed error's `message` is replaced by the canonical reason phrase and
140
+ * its `extra` is dropped. A framework can forward this (or `serializeError`'s
141
+ * result) without auditing what the message might contain.
142
+ *
143
+ * @param options - `expose: true` forces the real (masked) message + extra.
144
+ * @returns The client payload.
145
+ */
146
+ toClientPayload(options = {}) {
147
+ const expose = options.expose === true || this.expose;
148
+ const payload = {
149
+ name: this.name,
150
+ code: this.code,
151
+ message: expose ? maskCredentials(this.message) : genericStatusMessage(this.statusCode),
152
+ statusCode: this.statusCode
153
+ };
154
+ if (expose && this.extra !== void 0) payload.extra = this.extra;
155
+ return payload;
156
+ }
97
157
  };
98
158
  var DomainError = class extends AppError {
99
- constructor(code, message, extra) {
100
- super(code, message, ERROR_HTTP_STATUS[code] ?? 400, extra);
159
+ constructor(code, message, extra, expose) {
160
+ super(code, message, ERROR_HTTP_STATUS[code] ?? 400, extra, expose);
101
161
  this.name = "DomainError";
102
162
  }
103
163
  };
104
164
  var InfraError = class extends AppError {
105
- constructor(code, message, extra) {
106
- super(code, message, ERROR_HTTP_STATUS[code] ?? 500, extra);
165
+ constructor(code, message, extra, expose) {
166
+ super(code, message, ERROR_HTTP_STATUS[code] ?? 500, extra, expose);
107
167
  this.name = "InfraError";
108
168
  }
109
169
  };
110
170
  var BadRequest = class extends DomainError {
111
- constructor(message, extra) {
112
- super("BAD_REQUEST", message, extra);
171
+ constructor(message, extra, expose) {
172
+ super("BAD_REQUEST", message, extra, expose);
113
173
  this.name = "BadRequest";
114
174
  }
115
175
  };
@@ -170,7 +230,7 @@ var isMongoDuplicateKeyError = (error) => {
170
230
  };
171
231
  var isTransactionUnsupportedError = (error) => {
172
232
  const code = error?.code;
173
- if (code === 20) return true;
233
+ if (code === 20 || code === "TRANSACTIONS_UNSUPPORTED") return true;
174
234
  const message = error instanceof Error ? error.message : "";
175
235
  return /Transaction numbers are only allowed/i.test(message) || /transactions are not supported/i.test(message) || /does not support retryable writes/i.test(message);
176
236
  };
@@ -255,6 +315,12 @@ var mapMongoDriverError = (error, ctx = {}) => {
255
315
  }
256
316
  const code = error.code;
257
317
  switch (code) {
318
+ case 20:
319
+ return new InfraError(
320
+ "TRANSACTIONS_UNSUPPORTED",
321
+ "MongoDB deployment does not support transactions; enable allowNonTransactionalFallback to run non-atomically",
322
+ context
323
+ );
258
324
  case 112:
259
325
  return new DomainError("VERSION_CONFLICT", "Document version conflict", context);
260
326
  case 50:
@@ -279,13 +345,20 @@ var httpStatusForError = (err) => {
279
345
  if (isMongoTransientError(err)) return 503;
280
346
  return 500;
281
347
  };
282
- var serializeError = (err, ctx = {}) => {
348
+ var serializeError = (err, ctx = {}, options = {}) => {
283
349
  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 };
350
+ if (mapped instanceof AppError) return mapped.toClientPayload(options);
351
+ const statusCode = httpStatusForError(mapped);
352
+ const name = mapped instanceof Error ? mapped.name : "UnknownError";
353
+ if (options.expose !== true) {
354
+ return { name, code: "UNKNOWN", message: genericStatusMessage(statusCode), statusCode };
287
355
  }
288
- return { name: "UnknownError", code: "UNKNOWN", message: String(mapped), statusCode: 500 };
356
+ return {
357
+ name,
358
+ code: "UNKNOWN",
359
+ message: redactText(mapped instanceof Error ? mapped.message : String(mapped)),
360
+ statusCode
361
+ };
289
362
  };
290
363
  var cloneDeep = (value) => {
291
364
  if (value instanceof Date) return new Date(value.getTime());
@@ -341,46 +414,81 @@ var cloneDeep = (value) => {
341
414
  }
342
415
  return value;
343
416
  };
344
-
345
- // src/utils/hash.ts
346
- var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Date) && !(v instanceof RegExp) && // Mongo driver ObjectId (and other BSON values with a toHexString marker)
347
- // must fall through to `toPrimitive` — treating them as plain objects
348
- // would serialize every ObjectId as `{}` and collapse distinct values.
349
- typeof v.toHexString !== "function";
350
- var toPrimitive = (v) => {
351
- if (v === void 0 || v === null) return null;
352
- if (typeof v === "bigint") return v.toString();
353
- if (v instanceof Date) return `date:${v.getTime()}`;
354
- if (typeof v === "object") {
355
- const anyV = v;
356
- if (typeof anyV.toHexString === "function") return `oid:${anyV.toHexString()}`;
357
- if (anyV instanceof RegExp) return `re:${anyV.source}/${anyV.flags}`;
358
- }
359
- return v;
417
+ var { EJSON } = BSON;
418
+ var bsonTypeOf = (value) => {
419
+ const type = value._bsontype;
420
+ return typeof type === "string" ? type : void 0;
421
+ };
422
+ var isPlainObject = (value) => {
423
+ if (value === null || typeof value !== "object") return false;
424
+ const prototype = Object.getPrototypeOf(value);
425
+ return (prototype === Object.prototype || prototype === null) && bsonTypeOf(value) === void 0;
360
426
  };
361
427
  var stableStringify = (value) => {
362
- const visit = (v) => {
363
- if (Array.isArray(v)) return v.map(visit);
364
- if (isPlainObject(v)) {
365
- const out = {};
366
- for (const key of Object.keys(v).sort()) out[key] = visit(v[key]);
367
- return out;
428
+ const ancestors = /* @__PURE__ */ new WeakSet();
429
+ const visit = (input) => {
430
+ if (input === null) return { t: "null" };
431
+ if (input === void 0) return { t: "undefined" };
432
+ switch (typeof input) {
433
+ case "string":
434
+ case "boolean":
435
+ return { t: typeof input, v: input };
436
+ case "number":
437
+ if (Number.isNaN(input)) return { t: "number", v: "NaN" };
438
+ if (input === Infinity) return { t: "number", v: "Infinity" };
439
+ if (input === -Infinity) return { t: "number", v: "-Infinity" };
440
+ if (Object.is(input, -0)) return { t: "number", v: "-0" };
441
+ return { t: "number", v: input };
442
+ case "bigint":
443
+ return { t: "bigint", v: input.toString() };
444
+ case "symbol":
445
+ case "function":
446
+ throw new TypeError(`stableStringify: unsupported ${typeof input} value`);
447
+ }
448
+ const object = input;
449
+ if (ancestors.has(object)) throw new TypeError("stableStringify: cyclic value");
450
+ ancestors.add(object);
451
+ try {
452
+ if (input instanceof Date) {
453
+ return { t: "date", v: Number.isNaN(input.getTime()) ? "Invalid" : input.toISOString() };
454
+ }
455
+ if (input instanceof RegExp) return { t: "regexp", v: `re:${input.source}/${input.flags}` };
456
+ const bsonType = bsonTypeOf(object);
457
+ if (bsonType !== void 0) {
458
+ return { t: "bson", kind: bsonType, v: EJSON.stringify(input, { relaxed: false }) };
459
+ }
460
+ if (Array.isArray(input)) return { t: "array", v: input.map(visit) };
461
+ if (input instanceof Map) {
462
+ return {
463
+ t: "map",
464
+ v: [...input.entries()].map(([key, entry]) => [visit(key), visit(entry)])
465
+ };
466
+ }
467
+ if (input instanceof Set) return { t: "set", v: [...input].map(visit) };
468
+ if (Buffer.isBuffer(input)) return { t: "buffer", v: input.toString("base64") };
469
+ if (ArrayBuffer.isView(input)) {
470
+ return {
471
+ t: "bytes",
472
+ kind: input.constructor.name,
473
+ v: Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString("base64")
474
+ };
475
+ }
476
+ if (input instanceof ArrayBuffer) {
477
+ return { t: "arraybuffer", v: Buffer.from(input).toString("base64") };
478
+ }
479
+ if (isPlainObject(input)) {
480
+ const out = {};
481
+ for (const key of Object.keys(input).sort()) out[key] = visit(input[key]);
482
+ return { t: "object", v: out };
483
+ }
484
+ throw new TypeError(`stableStringify: unsupported object ${input.constructor.name}`);
485
+ } finally {
486
+ ancestors.delete(object);
368
487
  }
369
- return toPrimitive(v);
370
488
  };
371
489
  return JSON.stringify(visit(value));
372
490
  };
373
- var hashString = (input) => {
374
- let h1 = 5381;
375
- let h2 = 2166136261;
376
- for (let i = 0; i < input.length; i++) {
377
- const c = input.charCodeAt(i);
378
- h1 = (h1 << 5) + h1 + c | 0;
379
- h2 = (h2 ^ c) * 16777619 | 0;
380
- h2 = h2 << 3 | h2 >>> 29;
381
- }
382
- return `${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`;
383
- };
491
+ var hashString = (input) => createHash("sha256").update(input, "utf8").digest("base64url");
384
492
  var stableHash = (value) => hashString(stableStringify(value));
385
493
 
386
494
  // src/utils/lru.ts
@@ -618,16 +726,26 @@ var normalizeWatchId = (id) => {
618
726
  };
619
727
 
620
728
  // 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
- );
729
+ var STANDALONE = /only supported on replica sets|not supported on (?:a )?standalone/i;
730
+ var CREDENTIALS = /requires authentication|not authorized|unauthorized|authentication failed|Command .* requires authentication/i;
731
+ var explainPermanentWatchError = (message) => {
732
+ if (STANDALONE.test(message)) {
733
+ return {
734
+ reason: "standalone",
735
+ headline: "change streams need a replica set",
736
+ 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."
737
+ };
738
+ }
739
+ if (CREDENTIALS.test(message)) {
740
+ return {
741
+ reason: "credentials",
742
+ headline: "the connection is not authorized for change streams",
743
+ 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."
744
+ };
745
+ }
746
+ return void 0;
747
+ };
748
+ var isPermanentWatchError = (message) => explainPermanentWatchError(message) !== void 0;
631
749
 
632
750
  // src/cache/hot-cache/watcher.ts
633
751
  var resolveDbSafe = (ref, logger) => {
@@ -762,7 +880,7 @@ var WatchCoordinator = class {
762
880
  }
763
881
  }
764
882
  if (isPermanentWatchError(message)) {
765
- this.markStreamFailed(key);
883
+ this.markStreamFailed(key, explainPermanentWatchError(message));
766
884
  return;
767
885
  }
768
886
  }
@@ -771,12 +889,15 @@ var WatchCoordinator = class {
771
889
  await sleepJittered(backoff, 1e3, true);
772
890
  }
773
891
  }
774
- markStreamFailed(key) {
892
+ markStreamFailed(key, verdict) {
775
893
  this.failedStreams.add(key);
776
894
  if (!this.host.isReplica()) return;
777
895
  const planned = this.plannedStreamKeys();
778
896
  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");
897
+ this.host.logger.warn?.(
898
+ verdict === void 0 ? {} : { reason: verdict.reason, hint: verdict.hint },
899
+ `hot cache: change streams unsupported${verdict === void 0 ? "" : ` \u2014 ${verdict.headline}`}, falling back to ticker`
900
+ );
780
901
  this.host.fallbackToStandalone();
781
902
  }
782
903
  }
@@ -1194,7 +1315,7 @@ var createHotCache = (options) => new HotCache(options);
1194
1315
  // src/cache/query-cache.ts
1195
1316
  var SEP = "\0";
1196
1317
  var NS_SEP = "";
1197
- var cacheCollectionKey = (dbName, physical) => `${dbName}${NS_SEP}${physical}`;
1318
+ var cacheCollectionKey = (dbName, physical, clusterNamespace = "") => `${clusterNamespace}${NS_SEP}${dbName}${NS_SEP}${physical}`;
1198
1319
  var QueryCache = class {
1199
1320
  lru;
1200
1321
  index = /* @__PURE__ */ new Map();
@@ -1219,8 +1340,8 @@ var QueryCache = class {
1219
1340
  max: options.maxSize ?? 500,
1220
1341
  onEvict: (key) => this.unregisterKey(key)
1221
1342
  });
1222
- this.ttlMs = options.ttlMs ?? 0;
1223
- this.clone = options.clone ?? false;
1343
+ this.ttlMs = options.ttlMs ?? 3e4;
1344
+ this.clone = options.clone ?? true;
1224
1345
  }
1225
1346
  get size() {
1226
1347
  return this.lru.size;
@@ -1295,6 +1416,7 @@ var QueryCache = class {
1295
1416
  this.sets++;
1296
1417
  const ttl = ttlMs ?? this.ttlMs;
1297
1418
  const expiresAt = ttl > 0 ? Date.now() + ttl : Infinity;
1419
+ this.unregisterKey(key);
1298
1420
  this.lru.set(key, {
1299
1421
  value,
1300
1422
  expiresAt,
@@ -1353,14 +1475,24 @@ var withGracefulMongoTransaction = async (runner, fn, options = {}) => {
1353
1475
  }
1354
1476
  }
1355
1477
  }
1356
- if (!enabled) return fn(null);
1478
+ const unsupported = () => new InfraError(
1479
+ "TRANSACTIONS_UNSUPPORTED",
1480
+ "MongoDB deployment does not support transactions; set allowNonTransactionalFallback to run non-atomically"
1481
+ );
1482
+ if (!enabled) {
1483
+ if (options.allowNonTransactionalFallback === true) return fn(null);
1484
+ throw unsupported();
1485
+ }
1357
1486
  try {
1358
- return await runner.transaction(fn);
1487
+ return await runner.transaction(fn, {
1488
+ ...options.allowNonTransactionalFallback !== void 0 ? { allowNonTransactionalFallback: options.allowNonTransactionalFallback } : {}
1489
+ });
1359
1490
  } catch (err) {
1360
1491
  if (isTransactionUnsupportedError(err)) {
1492
+ if (options.allowNonTransactionalFallback !== true) throw err;
1361
1493
  options.logger?.warn?.(
1362
1494
  { error: err instanceof Error ? err.message : String(err) },
1363
- "transactions unsupported, falling back"
1495
+ "transactions unsupported; running explicitly requested non-transactional fallback"
1364
1496
  );
1365
1497
  return fn(null);
1366
1498
  }
@@ -1527,89 +1659,134 @@ var nextMigrationNumber = async (dir) => {
1527
1659
  return match ? Math.max(max, Number(match[1])) : max;
1528
1660
  }, 0) + 1;
1529
1661
  };
1530
-
1531
- // src/migrations/journal.ts
1532
1662
  var isDuplicateKeyError = (err) => {
1533
1663
  const code = err?.code;
1534
1664
  if (code === 11e3 || code === 11001) return true;
1535
1665
  return /E11000 duplicate key/.test(String(err?.message ?? ""));
1536
1666
  };
1667
+ var expiredLeaseFilter = (now, staleCutoff) => ({
1668
+ $or: [
1669
+ { expiresAt: { $lt: now } },
1670
+ { expiresAt: { $exists: false }, appliedAt: { $lt: staleCutoff } }
1671
+ ]
1672
+ });
1537
1673
  var createMigrationJournal = (manager) => {
1538
1674
  let indexReady = false;
1539
1675
  const ensureIndex = async () => {
1540
1676
  if (indexReady) return;
1541
- try {
1542
- await manager.createIndex?.(JOURNAL_COLLECTION, { name: 1 }, { unique: true });
1543
- } catch {
1677
+ if (manager.createIndex) {
1678
+ await manager.createIndex(JOURNAL_COLLECTION, { name: 1 }, { unique: true });
1544
1679
  }
1545
1680
  indexReady = true;
1546
1681
  };
1547
1682
  const appliedNames = async () => {
1548
- const rows = await manager.findMany(JOURNAL_COLLECTION);
1683
+ const rows = await manager.findMany(JOURNAL_COLLECTION, {}, { cache: false });
1549
1684
  return new Set(
1550
- rows.filter((row) => row.status === void 0 || row.status === "applied").map((row) => row.name)
1685
+ rows.filter(
1686
+ (row) => row.status === void 0 || row.status === "applied" || row.status === "rolling_back"
1687
+ ).map((row) => row.name)
1551
1688
  );
1552
1689
  };
1553
- const stealableFilter = (name, now, staleCutoff) => ({
1554
- name,
1555
- status: { $ne: "applied" },
1556
- $or: [
1557
- { expiresAt: { $lt: now } },
1558
- { expiresAt: { $exists: false }, appliedAt: { $lt: staleCutoff } },
1559
- { status: { $nin: ["running", "applied"] } }
1560
- ]
1561
- });
1562
1690
  const claim = async (name, file, leaseMs) => {
1563
1691
  await ensureIndex();
1564
1692
  const now = /* @__PURE__ */ new Date();
1565
- const expiresAt = new Date(Date.now() + leaseMs);
1693
+ const staleCutoff = new Date(Date.now() - leaseMs);
1694
+ const claimId = randomUUID();
1566
1695
  const stole = await manager.findOneAndUpdate(
1567
1696
  JOURNAL_COLLECTION,
1568
- stealableFilter(name, now, new Date(Date.now() - leaseMs)),
1569
- { $set: { status: "running", appliedAt: now, file, expiresAt } },
1570
- { returnDocument: "before" }
1697
+ {
1698
+ name,
1699
+ status: { $nin: ["applied", "rolling_back"] },
1700
+ ...expiredLeaseFilter(now, staleCutoff)
1701
+ },
1702
+ {
1703
+ $set: {
1704
+ status: "running",
1705
+ appliedAt: now,
1706
+ file,
1707
+ expiresAt: new Date(Date.now() + leaseMs),
1708
+ claimId
1709
+ }
1710
+ },
1711
+ { returnDocument: "before", cache: false }
1571
1712
  );
1572
- if (stole !== null) return true;
1713
+ if (stole !== null) return claimId;
1573
1714
  try {
1574
1715
  await manager.insertOne(JOURNAL_COLLECTION, {
1575
1716
  name,
1576
1717
  appliedAt: now,
1577
1718
  status: "running",
1578
1719
  file,
1579
- expiresAt
1720
+ expiresAt: new Date(Date.now() + leaseMs),
1721
+ claimId
1580
1722
  });
1581
- return true;
1723
+ return claimId;
1582
1724
  } catch (err) {
1583
- if (isDuplicateKeyError(err)) return false;
1725
+ if (isDuplicateKeyError(err)) return null;
1584
1726
  throw err;
1585
1727
  }
1586
1728
  };
1587
- const renew = async (name, leaseMs) => {
1729
+ const claimRollback = async (name, file, leaseMs) => {
1730
+ await ensureIndex();
1731
+ const now = /* @__PURE__ */ new Date();
1732
+ const claimId = randomUUID();
1733
+ const claimed = await manager.findOneAndUpdate(
1734
+ JOURNAL_COLLECTION,
1735
+ {
1736
+ name,
1737
+ $or: [
1738
+ { status: "applied" },
1739
+ { status: "rolling_back", ...expiredLeaseFilter(now, new Date(Date.now() - leaseMs)) }
1740
+ ]
1741
+ },
1742
+ {
1743
+ $set: {
1744
+ status: "rolling_back",
1745
+ file,
1746
+ claimId,
1747
+ expiresAt: new Date(Date.now() + leaseMs)
1748
+ }
1749
+ },
1750
+ { returnDocument: "before", cache: false }
1751
+ );
1752
+ return claimed === null ? null : claimId;
1753
+ };
1754
+ const renew = async (name, claimId, leaseMs) => {
1588
1755
  await manager.updateOne(
1589
1756
  JOURNAL_COLLECTION,
1590
- { name, status: "running" },
1591
- { $set: { expiresAt: new Date(Date.now() + leaseMs) } }
1757
+ { name, claimId, status: { $in: ["running", "rolling_back"] } },
1758
+ { $set: { expiresAt: new Date(Date.now() + leaseMs) } },
1759
+ { cache: false }
1592
1760
  );
1593
1761
  };
1594
- const markApplied = async (name) => {
1762
+ const markApplied = async (name, claimId) => {
1595
1763
  const res = await manager.updateOne(
1596
1764
  JOURNAL_COLLECTION,
1597
- { name, status: "running" },
1598
- { $set: { status: "applied" } }
1765
+ { name, claimId, status: "running" },
1766
+ { $set: { status: "applied" }, $unset: { expiresAt: "", claimId: "" } },
1767
+ { cache: false }
1599
1768
  );
1600
1769
  if (res?.matchedCount === 0) {
1601
1770
  await manager.insertOne(JOURNAL_COLLECTION, { name, appliedAt: /* @__PURE__ */ new Date(), status: "applied" }).catch(() => {
1602
1771
  });
1772
+ throw new Error(`Migration "${name}" lost its claim before it could be marked applied`);
1603
1773
  }
1604
1774
  };
1605
- const removeRunning = async (name) => {
1606
- await manager.deleteOne(JOURNAL_COLLECTION, { name, status: "running" }).catch(() => {
1775
+ const removeRunning = async (name, claimId) => {
1776
+ await manager.deleteOne(JOURNAL_COLLECTION, { name, claimId, status: "running" }, { cache: false }).catch(() => {
1607
1777
  });
1608
1778
  };
1609
- const unrecord = async (name) => {
1610
- await manager.deleteOne(JOURNAL_COLLECTION, { name });
1779
+ const unrecord = async (name, claimId) => {
1780
+ const result = await manager.deleteOne(
1781
+ JOURNAL_COLLECTION,
1782
+ { name, claimId, status: "rolling_back" },
1783
+ { cache: false }
1784
+ );
1785
+ if (result?.deletedCount === 0) {
1786
+ throw new Error(`Migration "${name}" lost its rollback claim`);
1787
+ }
1611
1788
  };
1612
- return { appliedNames, claim, renew, markApplied, removeRunning, unrecord };
1789
+ return { appliedNames, claim, claimRollback, renew, markApplied, removeRunning, unrecord };
1613
1790
  };
1614
1791
 
1615
1792
  // src/migrations/index.ts
@@ -1629,10 +1806,10 @@ var createMongoMigrationRunner = (service, options = {}) => {
1629
1806
  }
1630
1807
  return db;
1631
1808
  };
1632
- const startHeartbeat = (journal, name) => {
1809
+ const startHeartbeat = (journal, name, claimId) => {
1633
1810
  const timer = setInterval(
1634
1811
  () => {
1635
- void journal.renew(name, leaseMs).catch(() => {
1812
+ void journal.renew(name, claimId, leaseMs).catch(() => {
1636
1813
  });
1637
1814
  },
1638
1815
  Math.max(leaseMs / 2, 1e3)
@@ -1649,19 +1826,20 @@ var createMongoMigrationRunner = (service, options = {}) => {
1649
1826
  for (const file of pending) {
1650
1827
  const migration = await loadMigration(migrationDir, file);
1651
1828
  logger.info({ name: migration.name }, "running migration up");
1652
- if (!await journal.claim(migration.name, basename(file), leaseMs)) {
1829
+ const claimId = await journal.claim(migration.name, basename(file), leaseMs);
1830
+ if (claimId === null) {
1653
1831
  logger.info(
1654
1832
  { name: migration.name },
1655
1833
  "migration claimed by another runner or already applied, skipping"
1656
1834
  );
1657
1835
  continue;
1658
1836
  }
1659
- const stopHeartbeat = startHeartbeat(journal, migration.name);
1837
+ const stopHeartbeat = startHeartbeat(journal, migration.name, claimId);
1660
1838
  try {
1661
1839
  await migration.up({ service, logger, name: migration.name });
1662
- await journal.markApplied(migration.name);
1840
+ await journal.markApplied(migration.name, claimId);
1663
1841
  } catch (err) {
1664
- await journal.removeRunning(migration.name);
1842
+ await journal.removeRunning(migration.name, claimId);
1665
1843
  throw err;
1666
1844
  } finally {
1667
1845
  stopHeartbeat();
@@ -1684,9 +1862,19 @@ var createMongoMigrationRunner = (service, options = {}) => {
1684
1862
  const appliedFiles = files.filter((file) => applied.has(migrationNameOf(file))).reverse();
1685
1863
  for (const file of appliedFiles) {
1686
1864
  const migration = await loadMigration(migrationDir, file);
1865
+ const claimId = await journal.claimRollback(migration.name, basename(file), leaseMs);
1866
+ if (claimId === null) {
1867
+ logger.info({ name: migration.name }, "migration rollback already claimed, skipping");
1868
+ continue;
1869
+ }
1870
+ const stopHeartbeat = startHeartbeat(journal, migration.name, claimId);
1687
1871
  logger.info({ name: migration.name }, "running migration down");
1688
- await migration.down({ service, logger, name: migration.name });
1689
- await journal.unrecord(migration.name);
1872
+ try {
1873
+ await migration.down({ service, logger, name: migration.name });
1874
+ await journal.unrecord(migration.name, claimId);
1875
+ } finally {
1876
+ stopHeartbeat();
1877
+ }
1690
1878
  logger.info({ name: migration.name }, "migration rolled back");
1691
1879
  if (targetName !== void 0 && migration.name === targetName) break;
1692
1880
  }
@@ -2227,6 +2415,16 @@ var toMongoValidator = (schema) => ({
2227
2415
  });
2228
2416
 
2229
2417
  // src/schema/types.ts
2418
+ var SUPPORTED_STRING_FORMATS = /* @__PURE__ */ new Set([
2419
+ "email",
2420
+ "uri",
2421
+ "date-time",
2422
+ "date",
2423
+ "uuid",
2424
+ "hostname",
2425
+ "ipv4",
2426
+ "ipv6"
2427
+ ]);
2230
2428
  var chain = (field) => {
2231
2429
  const obj = field;
2232
2430
  obj.optional = () => chain({ ...field, flags: { ...field.flags, optional: true } });
@@ -2240,6 +2438,11 @@ function objectField(properties, opts = {}) {
2240
2438
  var s = {
2241
2439
  /** UTF-8 string field (optional `format` / length / regex constraints). */
2242
2440
  string(opts = {}) {
2441
+ if (opts.format !== void 0 && !SUPPORTED_STRING_FORMATS.has(opts.format)) {
2442
+ throw new Error(
2443
+ `Unsupported string format "${opts.format}"; supported: ${[...SUPPORTED_STRING_FORMATS].join(", ")}`
2444
+ );
2445
+ }
2243
2446
  return chain({ kind: "string", ...BASE, ...opts });
2244
2447
  },
2245
2448
  /** 32-bit integer field with optional bounds (min/max, exclusive, multipleOf). */
@@ -2405,6 +2608,200 @@ var checkReservedValue = (key, value, path, issues) => {
2405
2608
  `Reserved field "${key}" must be ${types.join(" | ")}`
2406
2609
  );
2407
2610
  };
2611
+ var typeMatches = (value, type) => {
2612
+ if (type === "number") return typeof value === "number" || value instanceof BSON.Long;
2613
+ if (type === "int") {
2614
+ return value instanceof BSON.Int32 || typeof value === "number" && Number.isInteger(value);
2615
+ }
2616
+ if (type === "long") {
2617
+ return value instanceof BSON.Long || typeof value === "number" && Number.isInteger(value);
2618
+ }
2619
+ if (type === "double") return typeof value === "number" || value instanceof BSON.Double;
2620
+ if (type === "decimal") return value instanceof BSON.Decimal128;
2621
+ if (type === "object")
2622
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2623
+ if (type === "array") return Array.isArray(value);
2624
+ if (type === "string") return typeof value === "string";
2625
+ if (type === "bool" || type === "boolean") return typeof value === "boolean";
2626
+ if (type === "date") return value instanceof Date;
2627
+ if (type === "objectId") return value instanceof BSON.ObjectId;
2628
+ if (type === "null") return value === null;
2629
+ if (type === "binData") return value instanceof BSON.Binary;
2630
+ if (type === "regex") return value instanceof RegExp;
2631
+ return true;
2632
+ };
2633
+ var comparable = (value) => {
2634
+ if (typeof value === "number") return value;
2635
+ if (value instanceof BSON.Long || value instanceof BSON.Int32 || value instanceof BSON.Double) {
2636
+ return Number(value.toString());
2637
+ }
2638
+ if (value instanceof BSON.Decimal128) return Number(value.toString());
2639
+ return void 0;
2640
+ };
2641
+ var validateRawSchema = (schema, value, path, issues) => {
2642
+ const types = schema.bsonType === void 0 ? [] : [schema.bsonType].flat();
2643
+ if (types.length > 0 && !types.some((type) => typeMatches(value, type))) {
2644
+ push(issues, path, "type", types.join(" | "), "BSON type does not match raw schema");
2645
+ return;
2646
+ }
2647
+ if (schema.enum && !schema.enum.some(
2648
+ (candidate) => BSON.EJSON.stringify(candidate) === BSON.EJSON.stringify(value)
2649
+ )) {
2650
+ push(issues, path, "enum", "raw enum", "Value is not in the allowed set");
2651
+ }
2652
+ if (typeof value === "string") {
2653
+ if (schema.minLength !== void 0 && value.length < schema.minLength) {
2654
+ push(issues, path, "constraint", `minLength ${schema.minLength}`, "String is too short");
2655
+ }
2656
+ if (schema.maxLength !== void 0 && value.length > schema.maxLength) {
2657
+ push(issues, path, "constraint", `maxLength ${schema.maxLength}`, "String is too long");
2658
+ }
2659
+ if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
2660
+ push(
2661
+ issues,
2662
+ path,
2663
+ "constraint",
2664
+ `pattern ${schema.pattern}`,
2665
+ "String does not match pattern"
2666
+ );
2667
+ }
2668
+ }
2669
+ const numeric = comparable(value);
2670
+ if (numeric !== void 0) {
2671
+ const minimum = typeof schema.exclusiveMinimum === "number" ? schema.exclusiveMinimum : schema.minimum;
2672
+ const maximum = typeof schema.exclusiveMaximum === "number" ? schema.exclusiveMaximum : schema.maximum;
2673
+ if (minimum !== void 0 && (numeric < minimum || schema.exclusiveMinimum === true && numeric === minimum)) {
2674
+ push(issues, path, "constraint", `minimum ${minimum}`, "Number is below minimum");
2675
+ }
2676
+ if (maximum !== void 0 && (numeric > maximum || schema.exclusiveMaximum === true && numeric === maximum)) {
2677
+ push(issues, path, "constraint", `maximum ${maximum}`, "Number is above maximum");
2678
+ }
2679
+ if (schema.multipleOf !== void 0 && Math.abs(numeric / schema.multipleOf - Math.round(numeric / schema.multipleOf)) > 1e-9) {
2680
+ push(
2681
+ issues,
2682
+ path,
2683
+ "constraint",
2684
+ `multipleOf ${schema.multipleOf}`,
2685
+ "Number is not a multiple"
2686
+ );
2687
+ }
2688
+ }
2689
+ if (Array.isArray(value)) {
2690
+ if (schema.minItems !== void 0 && value.length < schema.minItems) {
2691
+ push(issues, path, "constraint", `minItems ${schema.minItems}`, "Array is too short");
2692
+ }
2693
+ if (schema.maxItems !== void 0 && value.length > schema.maxItems) {
2694
+ push(issues, path, "constraint", `maxItems ${schema.maxItems}`, "Array is too long");
2695
+ }
2696
+ if (schema.uniqueItems) {
2697
+ const unique = new Set(value.map((item) => BSON.EJSON.stringify(item, { relaxed: false })));
2698
+ if (unique.size !== value.length) {
2699
+ push(issues, path, "constraint", "uniqueItems", "Array contains duplicate items");
2700
+ }
2701
+ }
2702
+ if (Array.isArray(schema.items)) {
2703
+ schema.items.forEach((item, index) => {
2704
+ if (index < value.length)
2705
+ validateRawSchema(item, value[index], `${path}[${index}]`, issues);
2706
+ });
2707
+ } else if (schema.items) {
2708
+ value.forEach((item, index) => {
2709
+ validateRawSchema(schema.items, item, `${path}[${index}]`, issues);
2710
+ });
2711
+ }
2712
+ }
2713
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
2714
+ const object = value;
2715
+ for (const required of schema.required ?? []) {
2716
+ if (object[required] === void 0)
2717
+ push(issues, `${path}.${required}`, "missing", "required", "Required field is missing");
2718
+ }
2719
+ for (const [key, child] of Object.entries(schema.properties ?? {})) {
2720
+ if (key in object)
2721
+ validateRawSchema(child, object[key], path ? `${path}.${key}` : key, issues);
2722
+ }
2723
+ for (const [pattern, child] of Object.entries(schema.patternProperties ?? {})) {
2724
+ for (const key of Object.keys(object)) {
2725
+ if (new RegExp(pattern).test(key))
2726
+ validateRawSchema(child, object[key], `${path}.${key}`, issues);
2727
+ }
2728
+ }
2729
+ if (schema.additionalProperties === false) {
2730
+ const known = /* @__PURE__ */ new Set([
2731
+ ...Object.keys(schema.properties ?? {}),
2732
+ ...Object.keys(object).filter(
2733
+ (key) => Object.keys(schema.patternProperties ?? {}).some(
2734
+ (pattern) => new RegExp(pattern).test(key)
2735
+ )
2736
+ )
2737
+ ]);
2738
+ for (const key of Object.keys(object)) {
2739
+ if (!known.has(key))
2740
+ push(
2741
+ issues,
2742
+ `${path}.${key}`,
2743
+ "unknown_key",
2744
+ "no additional properties",
2745
+ "Unexpected field"
2746
+ );
2747
+ }
2748
+ } else if (typeof schema.additionalProperties === "object") {
2749
+ for (const key of Object.keys(object)) {
2750
+ if (!(key in (schema.properties ?? {})))
2751
+ validateRawSchema(schema.additionalProperties, object[key], `${path}.${key}`, issues);
2752
+ }
2753
+ }
2754
+ const count = Object.keys(object).length;
2755
+ if (schema.minProperties !== void 0 && count < schema.minProperties)
2756
+ push(
2757
+ issues,
2758
+ path,
2759
+ "constraint",
2760
+ `minProperties ${schema.minProperties}`,
2761
+ "Too few properties"
2762
+ );
2763
+ if (schema.maxProperties !== void 0 && count > schema.maxProperties)
2764
+ push(
2765
+ issues,
2766
+ path,
2767
+ "constraint",
2768
+ `maxProperties ${schema.maxProperties}`,
2769
+ "Too many properties"
2770
+ );
2771
+ for (const [key, dependency] of Object.entries(schema.dependencies ?? {})) {
2772
+ if (!(key in object)) continue;
2773
+ if (Array.isArray(dependency)) {
2774
+ for (const required of dependency)
2775
+ if (!(required in object))
2776
+ push(issues, path, "constraint", "dependencies", `${key} requires ${required}`);
2777
+ } else validateRawSchema(dependency, object, path, issues);
2778
+ }
2779
+ }
2780
+ for (const child of schema.allOf ?? []) validateRawSchema(child, value, path, issues);
2781
+ if (schema.anyOf) {
2782
+ const matches = schema.anyOf.some((child) => {
2783
+ const candidate = [];
2784
+ validateRawSchema(child, value, path, candidate);
2785
+ return candidate.length === 0;
2786
+ });
2787
+ if (!matches) push(issues, path, "constraint", "anyOf", "No anyOf schema matched");
2788
+ }
2789
+ if (schema.oneOf) {
2790
+ const matches = schema.oneOf.filter((child) => {
2791
+ const candidate = [];
2792
+ validateRawSchema(child, value, path, candidate);
2793
+ return candidate.length === 0;
2794
+ }).length;
2795
+ if (matches !== 1)
2796
+ push(issues, path, "constraint", "oneOf", "Exactly one oneOf schema must match");
2797
+ }
2798
+ if (schema.not) {
2799
+ const candidate = [];
2800
+ validateRawSchema(schema.not, value, path, candidate);
2801
+ if (candidate.length === 0)
2802
+ push(issues, path, "constraint", "not", "Value matched a forbidden schema");
2803
+ }
2804
+ };
2408
2805
 
2409
2806
  // src/schema/validate-doc/field-validators.ts
2410
2807
  var patternCache = /* @__PURE__ */ new WeakMap();
@@ -2420,6 +2817,22 @@ var compiledPattern = (field) => {
2420
2817
  return void 0;
2421
2818
  }
2422
2819
  };
2820
+ var STRING_FORMAT_VALIDATORS = {
2821
+ email: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
2822
+ uri: (value) => {
2823
+ try {
2824
+ return new URL(value).protocol.length > 0;
2825
+ } catch {
2826
+ return false;
2827
+ }
2828
+ },
2829
+ "date-time": (value) => /^\d{4}-\d{2}-\d{2}T/.test(value) && !Number.isNaN(Date.parse(value)),
2830
+ date: (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`)),
2831
+ uuid: (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value),
2832
+ hostname: (value) => /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i.test(value),
2833
+ ipv4: (value) => isIP(value) === 4,
2834
+ ipv6: (value) => isIP(value) === 6
2835
+ };
2423
2836
  var stringValidator = (_deps, field, value, path, issues) => {
2424
2837
  const f = field;
2425
2838
  if (typeof value !== "string") {
@@ -2454,6 +2867,16 @@ var stringValidator = (_deps, field, value, path, issues) => {
2454
2867
  `"${value}" does not match pattern`
2455
2868
  );
2456
2869
  }
2870
+ const format = f.format ? STRING_FORMAT_VALIDATORS[f.format] : void 0;
2871
+ if (format && !format(value)) {
2872
+ push(
2873
+ issues,
2874
+ path,
2875
+ "constraint",
2876
+ `string(format ${f.format})`,
2877
+ `"${value}" is not a valid ${f.format}`
2878
+ );
2879
+ }
2457
2880
  };
2458
2881
  var numberValidator = (_deps, field, value, path, issues) => {
2459
2882
  const f = field;
@@ -2524,7 +2947,8 @@ var nullValidator = (_deps, _field, value, path, issues) => {
2524
2947
  };
2525
2948
  var anyValidator = () => {
2526
2949
  };
2527
- var rawValidator = () => {
2950
+ var rawValidator = (_deps, field, value, path, issues) => {
2951
+ validateRawSchema(field.fragment, value, path, issues);
2528
2952
  };
2529
2953
  var enumValidator = (_deps, field, value, path, issues) => {
2530
2954
  const f = field;
@@ -2794,13 +3218,13 @@ var CacheInvalidator = class {
2794
3218
  connectedAt = Date.now();
2795
3219
  if (backoff > 0) {
2796
3220
  this.cache.invalidateByCollection(
2797
- cacheCollectionKey(ref.db.databaseName, ref.collection)
3221
+ cacheCollectionKey(ref.db.databaseName, ref.collection, ref.namespace)
2798
3222
  );
2799
3223
  }
2800
3224
  const iterator = stream[Symbol.asyncIterator]();
2801
3225
  for await (const _change of iterator) {
2802
3226
  this.cache.invalidateByCollection(
2803
- cacheCollectionKey(ref.db.databaseName, ref.collection)
3227
+ cacheCollectionKey(ref.db.databaseName, ref.collection, ref.namespace)
2804
3228
  );
2805
3229
  }
2806
3230
  } catch (err) {
@@ -2814,13 +3238,19 @@ var CacheInvalidator = class {
2814
3238
  } catch {
2815
3239
  }
2816
3240
  }
2817
- if (isPermanentWatchError(message)) {
3241
+ const verdict = explainPermanentWatchError(message);
3242
+ if (verdict) {
2818
3243
  this.disabled = true;
2819
3244
  if (!this.warnedUnsupported) {
2820
3245
  this.warnedUnsupported = true;
2821
3246
  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"
3247
+ {
3248
+ collection: ref.collection,
3249
+ reason: verdict.reason,
3250
+ error: message,
3251
+ hint: verdict.hint
3252
+ },
3253
+ `cacheWatch: disabled \u2014 ${verdict.headline}; the query cache stays write-through only`
2824
3254
  );
2825
3255
  }
2826
3256
  return;
@@ -2911,7 +3341,8 @@ var makeGetDbUrl = (dbClients, config) => (name) => {
2911
3341
  };
2912
3342
  var makeConnectionFactory = (dbClients, db, getDbUrl, buildManager2, openedClients = /* @__PURE__ */ new Map()) => {
2913
3343
  const connecting = /* @__PURE__ */ new Map();
2914
- return async (names) => {
3344
+ let transition = Promise.resolve();
3345
+ const connect = async (names) => {
2915
3346
  const keys = names && names.length > 0 ? names : Object.keys(dbClients);
2916
3347
  const openedThisCall = [];
2917
3348
  const assignedThisCall = [];
@@ -2929,6 +3360,7 @@ var makeConnectionFactory = (dbClients, db, getDbUrl, buildManager2, openedClien
2929
3360
  const c = new MongoClient(dbUrl, {
2930
3361
  connectTimeoutMS: definition.connectTimeoutMs ?? 1e4,
2931
3362
  serverSelectionTimeoutMS: definition.connectTimeoutMs ?? 1e4,
3363
+ monitorCommands: definition.monitorCommands ?? false,
2932
3364
  ...definition.readPreference && { readPreference: definition.readPreference },
2933
3365
  ...definition.readConcern && { readConcern: definition.readConcern },
2934
3366
  ...definition.writeConcern && { writeConcern: definition.writeConcern }
@@ -2959,18 +3391,20 @@ var makeConnectionFactory = (dbClients, db, getDbUrl, buildManager2, openedClien
2959
3391
  throw err;
2960
3392
  }
2961
3393
  };
3394
+ return (names) => {
3395
+ const result = transition.then(() => connect(names));
3396
+ transition = result.catch(() => {
3397
+ });
3398
+ return result;
3399
+ };
2962
3400
  };
2963
3401
  var closeConnections = async (openedClients) => {
2964
- const closes = [...openedClients.values()].map((client) => {
2965
- try {
2966
- return Promise.resolve(client.close()).catch(() => {
2967
- });
2968
- } catch {
2969
- return Promise.resolve();
2970
- }
2971
- });
2972
- await Promise.allSettled(closes);
3402
+ const results = await Promise.allSettled(
3403
+ [...openedClients.values()].map((client) => client.close())
3404
+ );
2973
3405
  openedClients.clear();
3406
+ const errors = results.filter((result) => result.status === "rejected").map((result) => result.reason);
3407
+ if (errors.length > 0) throw new AggregateError(errors, "Failed to close MongoDB connections");
2974
3408
  };
2975
3409
 
2976
3410
  // src/service/health.ts
@@ -3012,9 +3446,19 @@ var createForeignKeyLoader = (deps2, collection, foreignField, options = {}) =>
3012
3446
  collection,
3013
3447
  { [foreignField]: { $in: keys } },
3014
3448
  {
3015
- ...options.maxDocs !== void 0 ? { limit: options.maxDocs } : {}
3449
+ limit: options.maxDocs !== void 0 ? options.maxDocs + 1 : 0,
3450
+ ...options.session !== void 0 ? { session: options.session } : {},
3451
+ ...options.maxTimeMS !== void 0 ? { maxTimeMS: options.maxTimeMS } : {},
3452
+ ...options.readPreference !== void 0 ? { readPreference: options.readPreference } : {},
3453
+ ...options.readConcern !== void 0 ? { readConcern: options.readConcern } : {},
3454
+ ...options.queryCache !== void 0 ? { queryCache: options.queryCache } : {}
3016
3455
  }
3017
3456
  );
3457
+ if (options.maxDocs !== void 0 && docs.length > options.maxDocs) {
3458
+ throw new BadRequest(
3459
+ `populate: ${collection}.${foreignField} exceeded maxDocs ${options.maxDocs}; raise the cap or narrow the relation`
3460
+ );
3461
+ }
3018
3462
  const index = /* @__PURE__ */ new Map();
3019
3463
  for (const doc of docs) {
3020
3464
  const value = doc[foreignField];
@@ -3177,8 +3621,11 @@ var traceDbOp = async (logger, meta, fn, options = {}) => {
3177
3621
  };
3178
3622
 
3179
3623
  // src/service/op-deps.ts
3180
- var makeOpDeps = (logger, dbLabel, resolve, wrapMongoErrors) => {
3181
- const trace = (meta2, fn) => traceDbOp(logger, meta2, fn, { wrapMongoErrors });
3624
+ var makeOpDeps = (logger, dbLabel, resolve, wrapMongoErrors, debugQuery) => {
3625
+ const trace = (meta2, fn) => traceDbOp(logger, meta2, fn, {
3626
+ wrapMongoErrors,
3627
+ ...debugQuery !== void 0 ? { debugQuery } : {}
3628
+ });
3182
3629
  const meta = (collection, op, params) => ({
3183
3630
  collection: String(collection),
3184
3631
  physicalCollection: resolve(String(collection)),
@@ -3580,20 +4027,20 @@ var createCachedAggregate = (ctx) => {
3580
4027
  const shouldDedupe = resolved.dedupe !== false && (resolved.dedupe === true || opts.dedupeReads === true && noSession) && opts.inFlight !== void 0;
3581
4028
  const runDriver = () => defineCrudOp(deps2, collection, opName, execute, options, false, { pipeline });
3582
4029
  const key = useCache ? cache.key(
3583
- cacheCollectionKey(client.databaseName, physical),
4030
+ cacheCollectionKey(client.databaseName, physical, opts.cacheNamespace),
3584
4031
  stableHash([opName, pipeline, options])
3585
4032
  ) : void 0;
3586
4033
  const toSourceKeys = () => collectAggSources(String(collection), pipeline, sources).map((name) => {
3587
4034
  try {
3588
- return cacheCollectionKey(client.databaseName, resolve(name));
4035
+ return cacheCollectionKey(client.databaseName, resolve(name), opts.cacheNamespace);
3589
4036
  } catch {
3590
- return cacheCollectionKey(client.databaseName, name);
4037
+ return cacheCollectionKey(client.databaseName, name, opts.cacheNamespace);
3591
4038
  }
3592
4039
  });
3593
4040
  const runOnce = () => {
3594
4041
  if (useCache && key) {
3595
4042
  const sourceKeys = toSourceKeys();
3596
- const primaryKey = cacheCollectionKey(client.databaseName, physical);
4043
+ const primaryKey = cacheCollectionKey(client.databaseName, physical, opts.cacheNamespace);
3597
4044
  const versions = { [primaryKey]: cache.versionOf(primaryKey) };
3598
4045
  for (const col of sourceKeys) {
3599
4046
  if (!(col in versions)) versions[col] = cache.versionOf(col);
@@ -3608,8 +4055,8 @@ var createCachedAggregate = (ctx) => {
3608
4055
  return runDriver();
3609
4056
  };
3610
4057
  if (shouldDedupe) {
3611
- const primaryKey = cacheCollectionKey(client.databaseName, physical);
3612
- const gen = cache !== void 0 ? cache.versionOf(primaryKey) : "";
4058
+ const primaryKey = cacheCollectionKey(client.databaseName, physical, opts.cacheNamespace);
4059
+ const gen = cache !== void 0 ? toSourceKeys().map((source) => `${source}:${cache.versionOf(source)}`).join("|") : "";
3613
4060
  const dedupeKey = `${primaryKey}|${gen}|${opName}|${stableHash([pipeline, options])}`;
3614
4061
  return await opts.inFlight.run(dedupeKey, runOnce);
3615
4062
  }
@@ -3818,7 +4265,13 @@ var makeTextSearchOp = (ctx) => {
3818
4265
  // src/service/aggregation/index.ts
3819
4266
  var makeAggregationOps = (client, dbLabel, logger, opts) => {
3820
4267
  const resolve = opts.resolveCollectionName;
3821
- const deps2 = makeOpDeps(logger, dbLabel, resolve, opts.wrapMongoErrors === true);
4268
+ const deps2 = makeOpDeps(
4269
+ logger,
4270
+ dbLabel,
4271
+ resolve,
4272
+ opts.wrapMongoErrors === true,
4273
+ opts.debugQuery
4274
+ );
3822
4275
  const coll = (collection) => client.collection(resolve(String(collection)));
3823
4276
  const ctx = {
3824
4277
  resolve,
@@ -3836,11 +4289,7 @@ var makeAggregationOps = (client, dbLabel, logger, opts) => {
3836
4289
  };
3837
4290
 
3838
4291
  // src/schema/apply-defaults.ts
3839
- var cloneDefault = (value) => {
3840
- if (value === null || typeof value !== "object") return value;
3841
- if (typeof structuredClone === "function") return structuredClone(value);
3842
- return Array.isArray(value) ? [...value] : { ...value };
3843
- };
4292
+ var cloneDefault = (value) => cloneDeep(value);
3844
4293
  var applySchemaDefaults = (schema, doc) => {
3845
4294
  if (!schema || doc === null || typeof doc !== "object" || Array.isArray(doc)) return;
3846
4295
  for (const [key, prop] of Object.entries(schema.properties)) {
@@ -3889,7 +4338,9 @@ var checkDocsDrift = (options, collection, op, docs) => {
3889
4338
  const schema = options.getSchema?.(collection);
3890
4339
  if (!schema) return;
3891
4340
  for (const doc of docs) {
3892
- const issues = validateDoc(schema, doc);
4341
+ const issues = validateDoc(schema, doc).filter(
4342
+ (issue) => options.partial !== true || issue.code !== "missing"
4343
+ );
3893
4344
  if (issues.length === 0) continue;
3894
4345
  const detail = issues.map((i) => `${i.path}: ${i.message}`).join("; ");
3895
4346
  const extra = { collection, db: options.db, op, issues };
@@ -3923,7 +4374,8 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3923
4374
  logger,
3924
4375
  dbLabel,
3925
4376
  resolve,
3926
- opts.wrapMongoErrors === true
4377
+ opts.wrapMongoErrors === true,
4378
+ opts.debugQuery
3927
4379
  );
3928
4380
  const coll = (name) => client.collection(resolve(String(name)));
3929
4381
  const toDriverFilter = (filter) => filter;
@@ -3931,7 +4383,7 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3931
4383
  const writeRun = (collection, opName, execute, options, params) => defineCrudOp(deps2, collection, opName, execute, options, true, params);
3932
4384
  const invalidate = (collection) => {
3933
4385
  opts.cache?.invalidateByCollection(
3934
- cacheCollectionKey(client.databaseName, resolve(String(collection)))
4386
+ cacheCollectionKey(client.databaseName, resolve(String(collection)), opts.cacheNamespace)
3935
4387
  );
3936
4388
  };
3937
4389
  const timestampsFor = (collection) => opts.timestamps?.[String(collection)];
@@ -3961,6 +4413,26 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3961
4413
  if (!ts?.updatedAt) return doc;
3962
4414
  return { ...doc, [ts.updatedAt]: /* @__PURE__ */ new Date() };
3963
4415
  };
4416
+ const validateWrite = (collection, doc, generateId = true) => {
4417
+ if (opts.validateWrites === false) return;
4418
+ const schema = opts.getSchema?.(String(collection));
4419
+ if (!schema) return;
4420
+ if (generateId && doc._id === void 0 && schema.properties._id?.kind === "objectId") {
4421
+ doc._id = new ObjectId();
4422
+ }
4423
+ const issues = validateDoc(schema, doc).filter(
4424
+ (issue) => generateId || issue.path !== "_id" || issue.code !== "missing"
4425
+ );
4426
+ if (issues.length > 0) {
4427
+ throw new DomainError("VALIDATION_FAILED", "Document failed schema validation", {
4428
+ db: client.databaseName,
4429
+ collection: String(collection),
4430
+ ...doc._id !== void 0 ? { documentId: doc._id } : {},
4431
+ fields: [...new Set(issues.map((issue) => issue.path))],
4432
+ issues
4433
+ });
4434
+ }
4435
+ };
3964
4436
  const read = async (collection, opName, filter, options, execute, shape = "none", keyExtra) => {
3965
4437
  const physical = resolve(String(collection));
3966
4438
  const resolved = resolveQueryOptions(options);
@@ -3973,14 +4445,15 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3973
4445
  const checkDrift = (value) => {
3974
4446
  const mode = resolveDriftMode(resolved.sdk.drift, opts.drift);
3975
4447
  if (mode === "off") return;
3976
- if (resolved.driverOpts.projection !== void 0) return;
4448
+ const partial = resolved.driverOpts.projection !== void 0;
3977
4449
  const docs = shape === "many" ? value : shape === "one" && value !== null ? [value] : [];
3978
4450
  checkDocsDrift(
3979
4451
  {
3980
4452
  logger,
3981
4453
  db: dbLabel,
3982
4454
  drift: mode,
3983
- ...opts.getSchema !== void 0 ? { getSchema: opts.getSchema } : {}
4455
+ ...opts.getSchema !== void 0 ? { getSchema: opts.getSchema } : {},
4456
+ partial
3984
4457
  },
3985
4458
  String(collection),
3986
4459
  opName,
@@ -4009,14 +4482,17 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
4009
4482
  }
4010
4483
  return payloadHash;
4011
4484
  };
4012
- const key = useCache ? opts.cache.key(cacheCollectionKey(client.databaseName, physical), getPayloadHash()) : void 0;
4485
+ const key = useCache ? opts.cache.key(
4486
+ cacheCollectionKey(client.databaseName, physical, opts.cacheNamespace),
4487
+ getPayloadHash()
4488
+ ) : void 0;
4013
4489
  const fetchAndCheck = () => executeWrapped().then((value) => {
4014
4490
  checkDrift(value);
4015
4491
  return value;
4016
4492
  });
4017
4493
  const runOnce = () => {
4018
4494
  if (useCache && key) {
4019
- const colKey = cacheCollectionKey(client.databaseName, physical);
4495
+ const colKey = cacheCollectionKey(client.databaseName, physical, opts.cacheNamespace);
4020
4496
  const versions = { [colKey]: opts.cache.versionOf(colKey) };
4021
4497
  const hit = opts.cache.get(key);
4022
4498
  if (hit !== void 0) return Promise.resolve(hit);
@@ -4028,7 +4504,7 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
4028
4504
  return fetchAndCheck();
4029
4505
  };
4030
4506
  if (shouldDedupe) {
4031
- const colKey = cacheCollectionKey(client.databaseName, physical);
4507
+ const colKey = cacheCollectionKey(client.databaseName, physical, opts.cacheNamespace);
4032
4508
  const gen = opts.cache !== void 0 ? opts.cache.versionOf(colKey) : "";
4033
4509
  return await opts.inFlight.run(`${colKey}|${gen}|${getPayloadHash()}`, runOnce);
4034
4510
  }
@@ -4058,6 +4534,7 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
4058
4534
  stampCreate,
4059
4535
  stampUpdate,
4060
4536
  stampReplace,
4537
+ validateWrite,
4061
4538
  read,
4062
4539
  normalizeFindOptions
4063
4540
  };
@@ -4282,7 +4759,16 @@ var makeWatchOps = (ctx) => {
4282
4759
  // src/service/crud/write-ops.ts
4283
4760
  var makeWriteOps = (ctx, readOps) => {
4284
4761
  const { opts } = ctx;
4285
- const { coll, toDriverFilter, writeRun, invalidate, stampCreate, stampUpdate, stampReplace } = ctx;
4762
+ const {
4763
+ coll,
4764
+ toDriverFilter,
4765
+ writeRun,
4766
+ invalidate,
4767
+ stampCreate,
4768
+ stampUpdate,
4769
+ stampReplace,
4770
+ validateWrite
4771
+ } = ctx;
4286
4772
  const { getOne } = readOps;
4287
4773
  const safeAfterHooks = async (collection, name, hookCtx) => {
4288
4774
  try {
@@ -4304,6 +4790,7 @@ var makeWriteOps = (ctx, readOps) => {
4304
4790
  doc
4305
4791
  });
4306
4792
  stampCreate(collection, doc);
4793
+ validateWrite(collection, doc);
4307
4794
  const result = await writeRun(
4308
4795
  collection,
4309
4796
  "mongo.insertOne",
@@ -4337,7 +4824,10 @@ var makeWriteOps = (ctx, readOps) => {
4337
4824
  collection: String(collection),
4338
4825
  docs
4339
4826
  });
4340
- for (const d of docs) stampCreate(collection, d);
4827
+ for (const d of docs) {
4828
+ stampCreate(collection, d);
4829
+ validateWrite(collection, d);
4830
+ }
4341
4831
  const result = await writeRun(
4342
4832
  collection,
4343
4833
  "mongo.insertMany",
@@ -4461,6 +4951,7 @@ var makeWriteOps = (ctx, readOps) => {
4461
4951
  filter
4462
4952
  });
4463
4953
  const effective = stampReplace(collection, replacement);
4954
+ validateWrite(collection, effective, false);
4464
4955
  const result = await writeRun(
4465
4956
  collection,
4466
4957
  "mongo.findOneAndReplace",
@@ -4494,6 +4985,8 @@ var makeWriteOps = (ctx, readOps) => {
4494
4985
  collection: String(collection),
4495
4986
  filter
4496
4987
  });
4988
+ const effective = stampReplace(collection, replacement);
4989
+ validateWrite(collection, effective, false);
4497
4990
  const result = await writeRun(
4498
4991
  collection,
4499
4992
  "mongo.replaceOne",
@@ -4504,7 +4997,7 @@ var makeWriteOps = (ctx, readOps) => {
4504
4997
  };
4505
4998
  return coll(collection).replaceOne(
4506
4999
  toDriverFilter(filter),
4507
- stampReplace(collection, replacement),
5000
+ effective,
4508
5001
  repOpts
4509
5002
  );
4510
5003
  },
@@ -4760,6 +5253,7 @@ var makeCrudOps = (client, dbLabel, logger, opts) => {
4760
5253
  ...makeWatchOps(ctx)
4761
5254
  };
4762
5255
  };
5256
+ var { EJSON: EJSON2 } = BSON;
4763
5257
  var tag = (value) => {
4764
5258
  if (value instanceof ObjectId) return { t: "oid", v: value.toHexString() };
4765
5259
  if (value instanceof Date) return { t: "date", v: value.toISOString() };
@@ -4767,6 +5261,9 @@ var tag = (value) => {
4767
5261
  if (typeof value === "number") return { t: "number", v: value };
4768
5262
  if (typeof value === "boolean") return { t: "boolean", v: value };
4769
5263
  if (value === null || value === void 0) return { t: "null" };
5264
+ if (typeof value === "object" && "_bsontype" in value) {
5265
+ return { t: "bson", v: EJSON2.stringify(value, { relaxed: false }) };
5266
+ }
4770
5267
  throw new BadRequest(
4771
5268
  `paginateCursor: unsupported sort value type "${typeof value}" \u2014 key values must be ObjectId, Date, string, number, boolean, or null`
4772
5269
  );
@@ -4800,6 +5297,11 @@ var untag = (tagged) => {
4800
5297
  return tagged.v;
4801
5298
  case "null":
4802
5299
  return null;
5300
+ case "bson":
5301
+ if (typeof tagged.v !== "string") {
5302
+ throw new BadRequest("paginateCursor: invalid cursor \u2014 BSON value must be a string");
5303
+ }
5304
+ return EJSON2.parse(tagged.v, { relaxed: false });
4803
5305
  }
4804
5306
  };
4805
5307
  var encodeCursor = (cursor) => Buffer.from(
@@ -4844,11 +5346,17 @@ var makePaginateCursor = (ctx) => {
4844
5346
  const { client, dbLabel, logger, opts, resolve, deps: deps2 } = ctx;
4845
5347
  const paginateCursor = async (collection, baseFilter, config) => {
4846
5348
  const sortKeys = Object.entries(config.sort);
5349
+ const uniqueTiebreaker = config.uniqueTiebreaker ?? "_id";
4847
5350
  if (sortKeys.length === 0) {
4848
5351
  throw new BadRequest(
4849
5352
  "paginateCursor: `sort` is required (include a unique tiebreaker like `_id`)"
4850
5353
  );
4851
5354
  }
5355
+ if (sortKeys.at(-1)?.[0] !== uniqueTiebreaker) {
5356
+ throw new BadRequest(
5357
+ `paginateCursor: the final sort key must be the unique tiebreaker "${uniqueTiebreaker}"`
5358
+ );
5359
+ }
4852
5360
  if (config.before !== void 0 && config.after !== void 0) {
4853
5361
  throw new BadRequest("paginateCursor: provide either `after` or `before`, not both");
4854
5362
  }
@@ -4904,10 +5412,15 @@ var makePaginateCursor = (ctx) => {
4904
5412
  "mongo.paginateCursor",
4905
5413
  page
4906
5414
  );
4907
- const boundary = backwards ? rows[limit] : page[page.length - 1];
5415
+ const boundary = backwards ? page[0] : page[page.length - 1];
4908
5416
  let nextCursor = null;
4909
5417
  if (hasMore && boundary !== void 0) {
4910
- const values = sortKeys.map(([f]) => boundary[f]);
5418
+ const values = sortKeys.map(
5419
+ ([f]) => f.split(".").reduce(
5420
+ (value, part) => value !== null && typeof value === "object" ? value[part] : void 0,
5421
+ boundary
5422
+ )
5423
+ );
4911
5424
  if (values.some((v) => v === void 0)) {
4912
5425
  throw new BadRequest(
4913
5426
  "paginateCursor: boundary document is missing a sort field \u2014 every sort key must exist on every document for keyset pagination"
@@ -4934,6 +5447,12 @@ var makePaginateFlexible = (ctx) => {
4934
5447
  // APIs used to disagree silently (10 vs 100).
4935
5448
  { limit: DEFAULT_FIND_LIMIT }
4936
5449
  );
5450
+ const maxOffset = config.maxOffset ?? 1e4;
5451
+ if (normalized.offset > maxOffset) {
5452
+ throw new BadRequest(
5453
+ `paginateFlexible: offset ${normalized.offset} exceeds maxOffset ${maxOffset}; use paginateCursor for deep pages`
5454
+ );
5455
+ }
4937
5456
  const dataStages = [
4938
5457
  ...config.prePipeline ?? [],
4939
5458
  ...config.sort && Object.keys(config.sort).length > 0 ? [{ $sort: config.sort }] : [],
@@ -4968,19 +5487,18 @@ var makePaginateFlexible = (ctx) => {
4968
5487
  const row = rows[0];
4969
5488
  const data = row?.data ?? [];
4970
5489
  const totalCount = row?.totalCount?.[0]?.count ?? 0;
4971
- if (config.projection === void 0) {
4972
- checkDocsDrift(
4973
- {
4974
- logger,
4975
- db: dbLabel,
4976
- drift: resolveDriftMode(config.queryOptions?.drift, opts.drift),
4977
- ...opts.getSchema !== void 0 ? { getSchema: opts.getSchema } : {}
4978
- },
4979
- String(collection),
4980
- "mongo.paginateFlexible",
4981
- data
4982
- );
4983
- }
5490
+ checkDocsDrift(
5491
+ {
5492
+ logger,
5493
+ db: dbLabel,
5494
+ drift: resolveDriftMode(config.queryOptions?.drift, opts.drift),
5495
+ ...opts.getSchema !== void 0 ? { getSchema: opts.getSchema } : {},
5496
+ partial: config.projection !== void 0
5497
+ },
5498
+ String(collection),
5499
+ "mongo.paginateFlexible",
5500
+ data
5501
+ );
4984
5502
  return buildPaginationResult(data, totalCount, normalized.page, normalized.limit);
4985
5503
  }
4986
5504
  });
@@ -4991,7 +5509,13 @@ var makePaginateFlexible = (ctx) => {
4991
5509
  // src/service/pagination/index.ts
4992
5510
  var makePaginationOps = (client, dbLabel, logger, opts) => {
4993
5511
  const resolve = opts.resolveCollectionName;
4994
- const deps2 = makeOpDeps(logger, dbLabel, resolve, opts.wrapMongoErrors === true);
5512
+ const deps2 = makeOpDeps(
5513
+ logger,
5514
+ dbLabel,
5515
+ resolve,
5516
+ opts.wrapMongoErrors === true,
5517
+ opts.debugQuery
5518
+ );
4995
5519
  const ctx = {
4996
5520
  client,
4997
5521
  dbLabel,
@@ -5008,16 +5532,37 @@ var makePaginationOps = (client, dbLabel, logger, opts) => {
5008
5532
  };
5009
5533
 
5010
5534
  // src/service/schema-ops.ts
5011
- var optionSignature = (o) => JSON.stringify([
5012
- Boolean(o?.unique),
5013
- Boolean(o?.sparse),
5014
- typeof o?.expireAfterSeconds === "number" ? o.expireAfterSeconds : null
5015
- ]);
5535
+ var optionSignature = (options) => {
5536
+ const normalized = {
5537
+ unique: Boolean(options?.unique),
5538
+ sparse: Boolean(options?.sparse),
5539
+ hidden: Boolean(options?.hidden),
5540
+ expireAfterSeconds: typeof options?.expireAfterSeconds === "number" ? options.expireAfterSeconds : null
5541
+ };
5542
+ for (const key of [
5543
+ "partialFilterExpression",
5544
+ "collation",
5545
+ "wildcardProjection",
5546
+ "weights",
5547
+ "default_language",
5548
+ "language_override",
5549
+ "textIndexVersion",
5550
+ "2dsphereIndexVersion",
5551
+ "bits",
5552
+ "min",
5553
+ "max",
5554
+ "bucketSize",
5555
+ "storageEngine"
5556
+ ]) {
5557
+ if (options?.[key] !== void 0) normalized[key] = options[key];
5558
+ }
5559
+ return stableStringify(normalized);
5560
+ };
5016
5561
  var indexKeyOf = (key, options) => `${JSON.stringify(Object.keys(key).map((k) => [k, key[k]]))}|${optionSignature(options)}`;
5017
5562
  var makeSchemaOps = (client, _logger, opts) => {
5018
- const readCollectionValidator = async (physical) => {
5563
+ const readCollectionValidation = async (physical) => {
5019
5564
  const info = await client.listCollections({ name: physical }).next();
5020
- return info?.options?.validator;
5565
+ return info?.options ?? {};
5021
5566
  };
5022
5567
  const ensureDeclaredIndexes = async (collection, physical, def) => {
5023
5568
  const declared = def?.indexes ?? [];
@@ -5031,7 +5576,7 @@ var makeSchemaOps = (client, _logger, opts) => {
5031
5576
  try {
5032
5577
  await handle.createIndex(index.key, index.options);
5033
5578
  } catch (err) {
5034
- if (err?.code === 85) {
5579
+ if ([85, 86].includes(err?.code ?? -1)) {
5035
5580
  throw new Error(
5036
5581
  `Cannot apply declared index on "${collection}": an index with the same name/key already exists with different options. Run db.syncIndexes("${collection}") to reconcile index drift.`,
5037
5582
  { cause: err }
@@ -5056,19 +5601,21 @@ var makeSchemaOps = (client, _logger, opts) => {
5056
5601
  created = false;
5057
5602
  }
5058
5603
  if (created) {
5059
- try {
5060
- await ensureDeclaredIndexes(collection, physical, def);
5061
- } catch (err) {
5062
- await client.dropCollection(physical).catch(() => {
5063
- });
5064
- throw err;
5065
- }
5604
+ await ensureDeclaredIndexes(collection, physical, def);
5066
5605
  return;
5067
5606
  }
5068
5607
  if (createOptions.validator) {
5069
- const current = await readCollectionValidator(physical);
5070
- if (JSON.stringify(current) !== JSON.stringify(createOptions.validator)) {
5071
- await client.command({ collMod: physical, validator: createOptions.validator });
5608
+ const current = await readCollectionValidation(physical);
5609
+ const collMod = {};
5610
+ if (stableStringify(current.validator) !== stableStringify(createOptions.validator)) {
5611
+ collMod.validator = createOptions.validator;
5612
+ }
5613
+ for (const key of ["validationLevel", "validationAction"]) {
5614
+ const requested = createOptions[key];
5615
+ if (requested !== void 0 && current[key] !== requested) collMod[key] = requested;
5616
+ }
5617
+ if (Object.keys(collMod).length > 0) {
5618
+ await client.command({ collMod: physical, ...collMod });
5072
5619
  }
5073
5620
  }
5074
5621
  await ensureDeclaredIndexes(collection, physical, def);
@@ -5084,6 +5631,7 @@ var makeSchemaOps = (client, _logger, opts) => {
5084
5631
  collMod: physical,
5085
5632
  validator: toMongoValidator(resolved)
5086
5633
  });
5634
+ if (def) def.schema = resolved;
5087
5635
  };
5088
5636
  const syncIndexes = async (collection) => {
5089
5637
  const physical = opts.resolveCollectionName(collection);
@@ -5097,52 +5645,68 @@ var makeSchemaOps = (client, _logger, opts) => {
5097
5645
  const declaredByKey = new Map(
5098
5646
  declared.map((d) => [indexKeyOf(d.key, d.options), d])
5099
5647
  );
5100
- const created = [];
5648
+ const keyPattern = (key) => JSON.stringify(Object.entries(key));
5101
5649
  const dropped = [];
5102
- for (const [key] of declaredByKey) {
5103
- if (!existingByKey.has(key)) {
5104
- await handle.createIndex(
5105
- declaredByKey.get(key).key,
5106
- declaredByKey.get(key).options
5107
- );
5108
- }
5109
- }
5110
- const after = (await handle.listIndexes().toArray()).filter((i) => i.name !== "_id_");
5111
- const afterByKey = new Map(
5112
- after.map((i) => [indexKeyOf(i.key, i), i.name])
5113
- );
5114
- for (const [key, spec] of declaredByKey) {
5115
- if (!existingByKey.has(key)) {
5116
- created.push(spec.options?.name ?? afterByKey.get(key) ?? JSON.stringify(spec.key));
5650
+ const droppedNames = /* @__PURE__ */ new Set();
5651
+ for (const spec of declaredByKey.values()) {
5652
+ const signature = indexKeyOf(spec.key, spec.options);
5653
+ if (existingByKey.has(signature)) continue;
5654
+ for (const index of existing) {
5655
+ const name = index.name;
5656
+ if (droppedNames.has(name)) continue;
5657
+ const sameName = spec.options?.name !== void 0 && index.name === spec.options.name;
5658
+ if (sameName || keyPattern(index.key) === keyPattern(spec.key)) {
5659
+ await handle.dropIndex(name);
5660
+ droppedNames.add(name);
5661
+ dropped.push(name);
5662
+ }
5117
5663
  }
5664
+ await handle.createIndex(spec.key, spec.options);
5118
5665
  }
5119
- for (const [key, name] of existingByKey) {
5120
- if (!declaredByKey.has(key)) {
5666
+ for (const [signature, name] of existingByKey) {
5667
+ if (!declaredByKey.has(signature) && !droppedNames.has(name)) {
5121
5668
  await handle.dropIndex(name);
5122
5669
  dropped.push(name);
5123
5670
  }
5124
5671
  }
5672
+ const after = (await handle.listIndexes().toArray()).filter((i) => i.name !== "_id_");
5673
+ const afterByKey = new Map(
5674
+ after.map((i) => [indexKeyOf(i.key, i), i.name])
5675
+ );
5676
+ const created = [...declaredByKey].filter(([signature]) => !existingByKey.has(signature)).map(
5677
+ ([signature, spec]) => spec.options?.name ?? afterByKey.get(signature) ?? JSON.stringify(spec.key)
5678
+ );
5125
5679
  return { created, dropped };
5126
5680
  };
5127
5681
  return { createSchema, updateSchema, syncIndexes };
5128
5682
  };
5129
-
5130
- // src/service/transaction.ts
5131
5683
  var makeTransactionOps = (client, handle, logger, _config, opts) => {
5132
5684
  let warnedUnsupported = false;
5133
5685
  let journalIndexReady = false;
5134
5686
  const MIGRATE_LEASE_MS = 12e4;
5135
- const transaction = async (fn, options) => {
5687
+ const transaction = async (fn, options = {}) => {
5688
+ const { allowNonTransactionalFallback, ...driverOptions } = options;
5689
+ const allowFallback = allowNonTransactionalFallback ?? opts.allowNonTransactionalFallback ?? false;
5136
5690
  const session = client.startSession();
5137
5691
  try {
5138
- return await session.withTransaction(async () => fn(session), options);
5692
+ return await session.withTransaction(async () => fn(session), driverOptions);
5139
5693
  } catch (err) {
5140
5694
  if (isTransactionUnsupportedError(err)) {
5695
+ if (!allowFallback) {
5696
+ if (opts.wrapMongoErrors) {
5697
+ throw new InfraError(
5698
+ "TRANSACTIONS_UNSUPPORTED",
5699
+ "MongoDB deployment does not support transactions; enable allowNonTransactionalFallback to run non-atomically",
5700
+ { db: handle.databaseName, op: "transaction" }
5701
+ );
5702
+ }
5703
+ throw err;
5704
+ }
5141
5705
  if (!warnedUnsupported) {
5142
5706
  warnedUnsupported = true;
5143
5707
  logger.warn?.(
5144
5708
  { error: err instanceof Error ? err.message : String(err) },
5145
- "transactions unsupported on this deployment, falling back to non-transactional"
5709
+ "transactions unsupported; running explicitly requested non-transactional fallback"
5146
5710
  );
5147
5711
  }
5148
5712
  return fn(null);
@@ -5152,16 +5716,14 @@ var makeTransactionOps = (client, handle, logger, _config, opts) => {
5152
5716
  }
5153
5717
  throw err;
5154
5718
  } finally {
5155
- await session.endSession();
5719
+ await session.endSession().catch(() => {
5720
+ });
5156
5721
  }
5157
5722
  };
5158
- const migrate = async (name, fn, filePath) => {
5723
+ const migrate = async (name, fn, filePath, options = {}) => {
5159
5724
  const journal = handle.collection(opts.migrationCollectionPhysical);
5160
5725
  if (!journalIndexReady) {
5161
- try {
5162
- await journal.createIndex({ name: 1 }, { unique: true });
5163
- } catch {
5164
- }
5726
+ await journal.createIndex({ name: 1 }, { unique: true });
5165
5727
  journalIndexReady = true;
5166
5728
  }
5167
5729
  const isDupKeyError = (err) => {
@@ -5173,10 +5735,11 @@ var makeTransactionOps = (client, handle, logger, _config, opts) => {
5173
5735
  const sessionOpts = session ? { session } : {};
5174
5736
  const now = /* @__PURE__ */ new Date();
5175
5737
  const expiresAt = new Date(Date.now() + MIGRATE_LEASE_MS);
5738
+ const claimId = randomUUID();
5176
5739
  const stole = await journal.findOneAndUpdate(
5177
5740
  {
5178
5741
  name,
5179
- status: { $exists: true, $nin: ["applied"] },
5742
+ status: { $exists: true, $nin: ["applied", "rolling_back"] },
5180
5743
  $or: [
5181
5744
  { expiresAt: { $lt: now } },
5182
5745
  {
@@ -5190,13 +5753,14 @@ var makeTransactionOps = (client, handle, logger, _config, opts) => {
5190
5753
  status: "running",
5191
5754
  appliedAt: now,
5192
5755
  expiresAt,
5756
+ claimId,
5193
5757
  ...filePath ? { file: filePath } : {}
5194
5758
  }
5195
5759
  },
5196
5760
  { returnDocument: "before", ...sessionOpts }
5197
5761
  );
5198
5762
  if (stole !== null) {
5199
- await runClaimed(session);
5763
+ await runClaimed(session, claimId);
5200
5764
  return;
5201
5765
  }
5202
5766
  try {
@@ -5206,6 +5770,7 @@ var makeTransactionOps = (client, handle, logger, _config, opts) => {
5206
5770
  appliedAt: now,
5207
5771
  status: "running",
5208
5772
  expiresAt,
5773
+ claimId,
5209
5774
  ...filePath ? { file: filePath } : {}
5210
5775
  },
5211
5776
  sessionOpts
@@ -5224,13 +5789,13 @@ var makeTransactionOps = (client, handle, logger, _config, opts) => {
5224
5789
  }
5225
5790
  return;
5226
5791
  }
5227
- await runClaimed(session);
5228
- });
5229
- async function runClaimed(session) {
5792
+ await runClaimed(session, claimId);
5793
+ }, options);
5794
+ async function runClaimed(session, claimId) {
5230
5795
  const sessionOpts = session ? { session } : {};
5231
5796
  const heartbeat = session === null ? setInterval(() => {
5232
5797
  void journal.updateOne(
5233
- { name, status: "running" },
5798
+ { name, claimId, status: "running" },
5234
5799
  { $set: { expiresAt: new Date(Date.now() + MIGRATE_LEASE_MS) } }
5235
5800
  ).catch(() => {
5236
5801
  });
@@ -5239,16 +5804,15 @@ var makeTransactionOps = (client, handle, logger, _config, opts) => {
5239
5804
  try {
5240
5805
  await fn(session);
5241
5806
  const res = await journal.updateOne(
5242
- { name, status: "running" },
5243
- { $set: { status: "applied" } },
5807
+ { name, claimId, status: "running" },
5808
+ { $set: { status: "applied" }, $unset: { expiresAt: "", claimId: "" } },
5244
5809
  sessionOpts
5245
5810
  );
5246
- if (res?.matchedCount === 0) {
5247
- await journal.insertOne({ name, appliedAt: /* @__PURE__ */ new Date(), status: "applied" }, sessionOpts).catch(() => {
5248
- });
5811
+ if (res.matchedCount === 0) {
5812
+ throw new Error(`Migration "${name}" lost its claim before it could be marked applied`);
5249
5813
  }
5250
5814
  } catch (err) {
5251
- await journal.deleteOne({ name }, sessionOpts).catch(() => {
5815
+ await journal.deleteOne({ name, claimId, status: "running" }, sessionOpts).catch(() => {
5252
5816
  });
5253
5817
  throw err;
5254
5818
  } finally {
@@ -5260,7 +5824,7 @@ var makeTransactionOps = (client, handle, logger, _config, opts) => {
5260
5824
  };
5261
5825
 
5262
5826
  // src/service/manager.ts
5263
- var buildManager = (deps2, dbKey, dbName, client) => {
5827
+ var buildManager = (deps2, dbKey, dbName, client, cacheNamespace = "") => {
5264
5828
  const { dbClients, config, logger, sharedCache, dedupeReads, inFlight, collectionRefs } = deps2;
5265
5829
  const definition = dbClients[dbKey];
5266
5830
  const handle = client.db(dbName);
@@ -5273,7 +5837,11 @@ var buildManager = (deps2, dbKey, dbName, client) => {
5273
5837
  registry.set(logical, def);
5274
5838
  hooks[logical] = def.hooks;
5275
5839
  if (def.timestamps) timestamps[logical] = def.timestamps;
5276
- collectionRefs.push({ db: handle, collection: baseResolveCollectionName(logical) });
5840
+ collectionRefs.push({
5841
+ db: handle,
5842
+ collection: baseResolveCollectionName(logical),
5843
+ namespace: cacheNamespace
5844
+ });
5277
5845
  }
5278
5846
  const getDefinition = (logical) => registry.get(logical);
5279
5847
  const resolveCollectionName = (logical) => {
@@ -5284,18 +5852,23 @@ var buildManager = (deps2, dbKey, dbName, client) => {
5284
5852
  };
5285
5853
  const crud = makeCrudOps(handle, dbName, logger, {
5286
5854
  resolveCollectionName,
5855
+ cacheNamespace,
5287
5856
  wrapMongoErrors: deps2.wrapMongoErrors,
5857
+ ...deps2.debugQuery !== void 0 ? { debugQuery: deps2.debugQuery } : {},
5288
5858
  ...sharedCache !== void 0 ? { cache: sharedCache } : {},
5289
5859
  dedupeReads,
5290
5860
  inFlight,
5291
5861
  hooks,
5292
5862
  timestamps,
5863
+ ...deps2.validateWrites !== void 0 ? { validateWrites: deps2.validateWrites } : {},
5293
5864
  ...deps2.drift !== void 0 ? { drift: deps2.drift } : {},
5294
5865
  ...getDefinition !== void 0 ? { getSchema: (logical) => getDefinition(logical)?.schema } : {}
5295
5866
  });
5296
5867
  const pagination = makePaginationOps(handle, dbName, logger, {
5297
5868
  resolveCollectionName,
5869
+ cacheNamespace,
5298
5870
  wrapMongoErrors: deps2.wrapMongoErrors,
5871
+ ...deps2.debugQuery !== void 0 ? { debugQuery: deps2.debugQuery } : {},
5299
5872
  ...deps2.drift !== void 0 ? { drift: deps2.drift } : {},
5300
5873
  ...getDefinition !== void 0 ? { getSchema: (logical) => getDefinition(logical)?.schema } : {},
5301
5874
  ...sharedCache !== void 0 ? { cache: sharedCache } : {},
@@ -5304,7 +5877,9 @@ var buildManager = (deps2, dbKey, dbName, client) => {
5304
5877
  });
5305
5878
  const aggregation = makeAggregationOps(handle, dbName, logger, {
5306
5879
  resolveCollectionName,
5880
+ cacheNamespace,
5307
5881
  wrapMongoErrors: deps2.wrapMongoErrors,
5882
+ ...deps2.debugQuery !== void 0 ? { debugQuery: deps2.debugQuery } : {},
5308
5883
  ...sharedCache !== void 0 ? { cache: sharedCache } : {},
5309
5884
  dedupeReads,
5310
5885
  inFlight
@@ -5312,16 +5887,14 @@ var buildManager = (deps2, dbKey, dbName, client) => {
5312
5887
  const schema = makeSchemaOps(handle, logger, { resolveCollectionName, getDefinition });
5313
5888
  const transactionOps = makeTransactionOps(client, handle, logger, config, {
5314
5889
  migrationCollectionPhysical: resolveCollectionName("_migrations"),
5315
- wrapMongoErrors: deps2.wrapMongoErrors
5890
+ wrapMongoErrors: deps2.wrapMongoErrors,
5891
+ ...deps2.allowNonTransactionalFallback !== void 0 ? { allowNonTransactionalFallback: deps2.allowNonTransactionalFallback } : {}
5316
5892
  });
5317
5893
  const populator = makePopulator({
5318
- findMany: async (logical, filter, options) => {
5319
- const physical = resolveCollectionName(logical);
5320
- return handle.collection(physical).find(filter, {
5321
- ...options?.projection !== void 0 ? { projection: options.projection } : {},
5322
- limit: options?.limit ?? 1e4
5323
- }).toArray();
5324
- }
5894
+ findMany: async (logical, filter, options) => crud.findMany(logical, filter, {
5895
+ ...options,
5896
+ cache: options?.queryCache ?? false
5897
+ })
5325
5898
  });
5326
5899
  const populate = (async (docs, relations, options = {}) => {
5327
5900
  const result = await populator.populate(docs, relations, options);
@@ -5335,6 +5908,7 @@ var buildManager = (deps2, dbKey, dbName, client) => {
5335
5908
  ...transactionOps,
5336
5909
  populate,
5337
5910
  client: handle,
5911
+ mongoClient: client,
5338
5912
  db: deps2.db
5339
5913
  };
5340
5914
  };
@@ -5362,25 +5936,44 @@ var createMongoService = (dbClients, configOverrides = {}) => {
5362
5936
  dedupeReads,
5363
5937
  inFlight,
5364
5938
  wrapMongoErrors: runtime.wrapMongoErrors,
5939
+ ...configOverrides.debugQuery !== void 0 ? { debugQuery: configOverrides.debugQuery } : {},
5940
+ ...configOverrides.allowNonTransactionalFallback !== void 0 ? { allowNonTransactionalFallback: configOverrides.allowNonTransactionalFallback } : {},
5941
+ ...configOverrides.validateWrites !== void 0 ? { validateWrites: configOverrides.validateWrites } : {},
5365
5942
  ...configOverrides.drift !== void 0 ? { drift: configOverrides.drift } : {},
5366
5943
  collectionRefs,
5367
5944
  db
5368
5945
  },
5369
5946
  key,
5370
5947
  dbName,
5371
- client
5948
+ client,
5949
+ stableHash(getDbUrl(key))
5372
5950
  )),
5373
5951
  openedClients
5374
5952
  );
5375
- const makeConnections = async (names) => {
5953
+ let lifecycle = Promise.resolve();
5954
+ const serializeLifecycle = (operation) => {
5955
+ const result = lifecycle.then(operation, operation);
5956
+ lifecycle = result.then(
5957
+ () => {
5958
+ },
5959
+ () => {
5960
+ }
5961
+ );
5962
+ return result;
5963
+ };
5964
+ const makeConnections = (names) => serializeLifecycle(async () => {
5376
5965
  await baseMakeConnections(names);
5377
5966
  await cacheInvalidator?.start(collectionRefs);
5378
- };
5379
- const closeConnections2 = async () => {
5967
+ });
5968
+ const closeConnections2 = () => serializeLifecycle(async () => {
5380
5969
  await cacheInvalidator?.stop();
5381
5970
  collectionRefs.length = 0;
5382
- await closeConnections(openedClients);
5383
- };
5971
+ try {
5972
+ await closeConnections(openedClients);
5973
+ } finally {
5974
+ for (const key of Object.keys(db)) delete db[key];
5975
+ }
5976
+ });
5384
5977
  const eachDb = async (fn) => {
5385
5978
  return Promise.all(Object.keys(db).map((key) => fn(db[key], key.replace(/Client$/, ""))));
5386
5979
  };