@ignex/ninox 0.1.3 → 0.1.5

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,4 +1,4 @@
1
- import { ObjectId, MongoClient, Decimal128 } from 'mongodb';
1
+ import { ObjectId, Decimal128, Long, Int32, Double, Timestamp, Binary, MinKey, MaxKey, DBRef, Code, MongoClient } from 'mongodb';
2
2
  import { writeFile, mkdir, readdir } from 'fs/promises';
3
3
  import { basename, join } from 'path';
4
4
  import { pathToFileURL } from 'url';
@@ -16,8 +16,11 @@ var withTimeout = (promise, timeoutMs, label) => {
16
16
  if (timer !== void 0) clearTimeout(timer);
17
17
  });
18
18
  };
19
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
- var sleepJittered = (ms, jitterMs = 1e3) => sleep(ms + Math.floor(Math.random() * Math.min(ms, jitterMs)));
19
+ var sleep = (ms, unref = false) => new Promise((resolve) => {
20
+ const timer = setTimeout(resolve, ms);
21
+ if (unref) timer.unref?.();
22
+ });
23
+ var sleepJittered = (ms, jitterMs = 1e3, unref = false) => sleep(ms + Math.floor(Math.random() * Math.min(ms, jitterMs)), unref);
21
24
 
22
25
  // src/capabilities.ts
23
26
  var createMongoCapabilitiesStore = () => {
@@ -53,14 +56,6 @@ var probeMongoCapabilities = async (client) => {
53
56
  return { probed: false, transactionsSupported: false };
54
57
  }
55
58
  };
56
- var mongoTransactionsEnabled = (store, options = {}) => {
57
- const env = options.envOverride ?? readMongoTransactionsEnvOverride();
58
- if (env !== void 0) return env;
59
- const state = store.get();
60
- if (state.probed) return state.transactionsSupported;
61
- if (options.urlHint) return /replicaSet=/.test(options.urlHint);
62
- return false;
63
- };
64
59
 
65
60
  // src/errors/classes.ts
66
61
  var ERROR_HTTP_STATUS = {
@@ -82,7 +77,7 @@ var AppError = class extends Error {
82
77
  this.name = "AppError";
83
78
  this.code = code;
84
79
  this.statusCode = statusCode;
85
- this.extra = extra;
80
+ if (extra !== void 0) this.extra = extra;
86
81
  }
87
82
  /**
88
83
  * Stable, client-safe serialization for framework error middleware: a
@@ -135,8 +130,14 @@ var TRANSIENT_MONGO_ERROR_CODES = Object.freeze(
135
130
  // ShutdownInProgress
136
131
  189,
137
132
  // PrimarySteppedDown
133
+ 251,
134
+ // NoSuchTransaction (stepdown mid-transaction)
135
+ 262,
136
+ // ExceededTimeLimit (part of the retryable transaction set)
138
137
  9001,
139
138
  // SocketException
139
+ 10058,
140
+ // NotWritablePrimary (legacy server code)
140
141
  10107,
141
142
  // NotWritablePrimary
142
143
  11600,
@@ -149,10 +150,13 @@ var TRANSIENT_MONGO_ERROR_CODES = Object.freeze(
149
150
  // NotPrimaryOrSecondary
150
151
  ])
151
152
  );
152
- var TRANSIENT_NAME = /MongoNetworkError|PoolClearedError|MongoWriteConcernError|MongoNotPrimaryError|MongoNodeIsRecoveringError|MongoNetworkTimeoutError/;
153
+ var TRANSIENT_NAME = /MongoNetworkError|PoolClearedError|MongoWriteConcernError|MongoNotPrimaryError|MongoNodeIsRecoveringError|MongoNetworkTimeoutError|MongoServerSelectionError/;
153
154
  var isMongoTransientError = (error) => {
154
155
  if (!error || typeof error !== "object") return false;
155
156
  const e = error;
157
+ if (typeof e.hasErrorLabel === "function" && e.hasErrorLabel("TransientTransactionError")) {
158
+ return true;
159
+ }
156
160
  if (typeof e.name === "string" && TRANSIENT_NAME.test(e.name)) return true;
157
161
  if (typeof e.code === "number" && TRANSIENT_MONGO_ERROR_CODES.has(e.code)) return true;
158
162
  return false;
@@ -164,6 +168,42 @@ var isMongoDuplicateKeyError = (error) => {
164
168
  if (typeof e.message === "string" && /E11000|duplicate key/i.test(e.message)) return true;
165
169
  return false;
166
170
  };
171
+ var isTransactionUnsupportedError = (error) => {
172
+ const code = error?.code;
173
+ if (code === 20) return true;
174
+ const message = error instanceof Error ? error.message : "";
175
+ 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
+ };
177
+ var MONGO_ERROR_NAME = /MongoError|MongoServerError|MongoBulkWriteError|MongoNetworkError|MongoNetworkTimeoutError|MongoWriteConcernError|MongoNotPrimaryError|MongoNodeIsRecoveringError|MongoCursorExhaustedError|PoolClearedError|MongoTopologyClosedError|MongoServerSelectionError/;
178
+ var DRIVER_ERROR_CODES = Object.freeze(
179
+ /* @__PURE__ */ new Set([
180
+ 20,
181
+ // IllegalOperation (transactions unsupported)
182
+ 26,
183
+ // NamespaceNotFound
184
+ 48,
185
+ // NamespaceExists
186
+ 50,
187
+ // MaxTimeMSExpired
188
+ 112,
189
+ // WriteConflict
190
+ 121,
191
+ // DocumentValidationFailure
192
+ 11e3,
193
+ // DuplicateKey
194
+ 11001,
195
+ // DuplicateKey (legacy)
196
+ ...TRANSIENT_MONGO_ERROR_CODES
197
+ ])
198
+ );
199
+ var isMongoDriverError = (error) => {
200
+ if (!error || typeof error !== "object") return false;
201
+ const e = error;
202
+ if (typeof e.name === "string" && MONGO_ERROR_NAME.test(e.name)) return true;
203
+ if (typeof e.code === "number" && DRIVER_ERROR_CODES.has(e.code)) return true;
204
+ if (typeof e.message === "string" && /E11000 duplicate key/i.test(e.message)) return true;
205
+ return false;
206
+ };
167
207
 
168
208
  // src/errors/driver-map.ts
169
209
  var extractValidationPaths = (errInfo) => {
@@ -192,6 +232,7 @@ var addValidationContext = (context, errInfo) => {
192
232
  var isBulkWriteError = (error) => typeof error === "object" && error !== null && "writeErrors" in error && Array.isArray(error.writeErrors);
193
233
  var mapMongoDriverError = (error, ctx = {}) => {
194
234
  if (error instanceof AppError) return error;
235
+ if (!isMongoDriverError(error)) return error;
195
236
  const context = { ...ctx };
196
237
  if (isMongoDuplicateKeyError(error)) {
197
238
  const e = error;
@@ -204,7 +245,7 @@ var mapMongoDriverError = (error, ctx = {}) => {
204
245
  if (first?.code === 11e3) {
205
246
  if (first.keyPattern) context.keyPattern = first.keyPattern;
206
247
  if (first.keyValue) context.keyValue = first.keyValue;
207
- return new DomainError("DUPLICATE_KEY", first.errmsg ?? "Duplicate key error", context);
248
+ return new DomainError("DUPLICATE_KEY", "Duplicate key error", context);
208
249
  }
209
250
  if (first?.code === 121) {
210
251
  addValidationContext(context, first.errInfo);
@@ -249,7 +290,47 @@ var serializeError = (err, ctx = {}) => {
249
290
  var cloneDeep = (value) => {
250
291
  if (value instanceof Date) return new Date(value.getTime());
251
292
  if (value instanceof ObjectId) return new ObjectId(value.toHexString());
293
+ if (value instanceof Decimal128) return Decimal128.fromString(value.toString());
294
+ if (value instanceof Long) return Long.fromString(value.toString());
295
+ if (value instanceof Int32) return new Int32(value.valueOf());
296
+ if (value instanceof Double) return new Double(value.valueOf());
297
+ if (value instanceof Timestamp)
298
+ return Timestamp.fromBits(value.getLowBits(), value.getHighBits());
299
+ if (value instanceof Binary) {
300
+ const bytes = Buffer.from(value.buffer);
301
+ return new Binary(bytes, value.sub_type);
302
+ }
303
+ if (value instanceof MinKey) return new MinKey();
304
+ if (value instanceof MaxKey) return new MaxKey();
305
+ if (value instanceof DBRef) {
306
+ return new DBRef(value.collection, cloneDeep(value.oid), value.db);
307
+ }
308
+ if (value instanceof Code) {
309
+ return new Code(
310
+ value.code,
311
+ value.scope !== void 0 ? cloneDeep(value.scope) : void 0
312
+ );
313
+ }
252
314
  if (value instanceof RegExp) return new RegExp(value.source, value.flags);
315
+ if (value instanceof Map) {
316
+ const out = /* @__PURE__ */ new Map();
317
+ for (const [k, v] of value) out.set(cloneDeep(k), cloneDeep(v));
318
+ return out;
319
+ }
320
+ if (value instanceof Set) {
321
+ const out = /* @__PURE__ */ new Set();
322
+ for (const v of value) out.add(cloneDeep(v));
323
+ return out;
324
+ }
325
+ if (Buffer.isBuffer(value)) return Buffer.from(value);
326
+ if (ArrayBuffer.isView(value)) {
327
+ const view = value;
328
+ if (view instanceof DataView) {
329
+ const bytes = view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength);
330
+ return new DataView(bytes);
331
+ }
332
+ return typeof view.slice === "function" ? view.slice() : value;
333
+ }
253
334
  if (Array.isArray(value)) return value.map((item) => cloneDeep(item));
254
335
  if (value !== null && typeof value === "object") {
255
336
  const out = {};
@@ -262,7 +343,10 @@ var cloneDeep = (value) => {
262
343
  };
263
344
 
264
345
  // src/utils/hash.ts
265
- var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
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";
266
350
  var toPrimitive = (v) => {
267
351
  if (v === void 0 || v === null) return null;
268
352
  if (typeof v === "bigint") return v.toString();
@@ -287,11 +371,15 @@ var stableStringify = (value) => {
287
371
  return JSON.stringify(visit(value));
288
372
  };
289
373
  var hashString = (input) => {
290
- let hash = 5381;
374
+ let h1 = 5381;
375
+ let h2 = 2166136261;
291
376
  for (let i = 0; i < input.length; i++) {
292
- hash = (hash << 5) + hash + input.charCodeAt(i) | 0;
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;
293
381
  }
294
- return (hash >>> 0).toString(36);
382
+ return `${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`;
295
383
  };
296
384
  var stableHash = (value) => hashString(stableStringify(value));
297
385
 
@@ -299,11 +387,13 @@ var stableHash = (value) => hashString(stableStringify(value));
299
387
  var LRU = class {
300
388
  max;
301
389
  map = /* @__PURE__ */ new Map();
390
+ onEvict;
302
391
  /** Number of entries evicted to make room for new ones (capacity evictions only). */
303
392
  evictions = 0;
304
393
  constructor(options) {
305
394
  if (options.max <= 0) throw new Error("LRU max must be > 0");
306
395
  this.max = options.max;
396
+ this.onEvict = options.onEvict;
307
397
  }
308
398
  get size() {
309
399
  return this.map.size;
@@ -325,10 +415,16 @@ var LRU = class {
325
415
  if (this.map.has(key)) this.map.delete(key);
326
416
  this.map.set(key, value);
327
417
  if (this.map.size > this.max) {
328
- const oldest = this.map.keys().next().value;
329
- if (oldest !== void 0) {
330
- this.map.delete(oldest);
418
+ const next = this.map.keys().next();
419
+ if (next.done !== true) {
420
+ const evictedKey = next.value;
421
+ const evictedValue = this.map.get(evictedKey);
422
+ this.map.delete(evictedKey);
331
423
  this.evictions++;
424
+ try {
425
+ this.onEvict?.(evictedKey, evictedValue);
426
+ } catch {
427
+ }
332
428
  }
333
429
  }
334
430
  return this;
@@ -359,9 +455,20 @@ var InFlight = class {
359
455
  run(key, fn) {
360
456
  const existing = this.pending.get(key);
361
457
  if (existing) return existing;
362
- const promise = fn().finally(() => {
363
- this.pending.delete(key);
364
- });
458
+ let promise;
459
+ try {
460
+ promise = Promise.resolve(fn());
461
+ } catch (err) {
462
+ promise = Promise.reject(err);
463
+ }
464
+ promise.then(
465
+ () => {
466
+ this.pending.delete(key);
467
+ },
468
+ () => {
469
+ this.pending.delete(key);
470
+ }
471
+ );
365
472
  this.pending.set(key, promise);
366
473
  return promise;
367
474
  }
@@ -369,24 +476,50 @@ var InFlight = class {
369
476
  this.pending.clear();
370
477
  }
371
478
  };
372
- var estimateSize = (value) => {
479
+ var OVERHEAD_PER_KEY = 8;
480
+ var estimateSize = (value, seen) => {
373
481
  if (value === null || value === void 0) return 0;
374
482
  if (typeof value === "string") return value.length * 2;
375
483
  if (typeof value === "number" || typeof value === "boolean") return 8;
484
+ if (typeof value === "bigint") return 16;
485
+ if (typeof value !== "object") return 0;
376
486
  if (value instanceof ObjectId) return 16;
377
487
  if (value instanceof Date) return 8;
378
- if (Array.isArray(value)) return value.reduce((sum, v) => sum + estimateSize(v), 0);
379
- if (typeof value === "object") {
380
- let sum = 0;
381
- for (const [k, v] of Object.entries(value)) {
382
- sum += k.length * 2 + estimateSize(v);
488
+ const visit = (v) => {
489
+ if (v !== null && typeof v === "object") {
490
+ if (!seen) seen = /* @__PURE__ */ new WeakSet();
491
+ else if (seen.has(v)) return OVERHEAD_PER_KEY;
492
+ seen.add(v);
383
493
  }
384
- return sum;
494
+ return estimateSize(v, seen);
495
+ };
496
+ if (Array.isArray(value)) {
497
+ let sum2 = OVERHEAD_PER_KEY * Math.max(value.length, 1);
498
+ for (const v of value) sum2 += visit(v);
499
+ return sum2;
385
500
  }
386
- return 0;
501
+ if (value instanceof Map) {
502
+ let sum2 = OVERHEAD_PER_KEY * value.size;
503
+ for (const [k, v] of value) sum2 += estimateSize(k, seen) + visit(v);
504
+ return sum2;
505
+ }
506
+ if (value instanceof Set) {
507
+ let sum2 = OVERHEAD_PER_KEY * value.size;
508
+ for (const v of value) sum2 += visit(v);
509
+ return sum2;
510
+ }
511
+ if (ArrayBuffer.isView(value)) {
512
+ return value.byteLength + OVERHEAD_PER_KEY;
513
+ }
514
+ let sum = 0;
515
+ for (const [k, v] of Object.entries(value)) {
516
+ sum += k.length * 2 + OVERHEAD_PER_KEY + visit(v);
517
+ }
518
+ return sum;
387
519
  };
388
520
 
389
521
  // src/cache/hot-cache/ticker.ts
522
+ var MAX_TICK_INFLIGHT = 64;
390
523
  var RefreshTicker = class {
391
524
  constructor(host, tickIntervalMs) {
392
525
  this.host = host;
@@ -421,17 +554,26 @@ var RefreshTicker = class {
421
554
  this.timer = void 0;
422
555
  }
423
556
  }
424
- /** Background refresh pass: re-run loaders for due entries, swap fresh values. */
557
+ /**
558
+ * Background refresh pass: re-run loaders for due entries, swap fresh values.
559
+ * A per-tick in-flight budget bounds the stampede after downtime/throttling —
560
+ * one sweep can't launch unbounded simultaneous loaders across all queries;
561
+ * skipped entries stay due and are picked up by the next tick.
562
+ */
425
563
  tick() {
426
564
  const now = Date.now();
565
+ let budget = MAX_TICK_INFLIGHT;
427
566
  for (const q of this.host.queries().values()) {
428
567
  if (q.refreshIntervalMs <= 0) continue;
429
568
  for (const [key, entry] of [...q.lru.entries()]) {
430
569
  if (entry.nextRefreshAt <= now) {
570
+ if (budget <= 0) return;
571
+ budget--;
572
+ entry.nextRefreshAt = now + q.refreshIntervalMs;
431
573
  q.refreshes++;
432
574
  void this.host.fetch(q, key, entry.args, q.gen).catch((err) => {
433
575
  const current = q.lru.peek(key);
434
- if (current) current.nextRefreshAt = now + q.refreshIntervalMs;
576
+ if (current) current.nextRefreshAt = Date.now();
435
577
  this.host.logger.warn?.(
436
578
  {
437
579
  error: err instanceof Error ? err.message : String(err),
@@ -465,8 +607,44 @@ var RefreshTicker = class {
465
607
 
466
608
  // src/cache/hot-cache/types.ts
467
609
  var WATCH_SEP = "\0";
610
+ var resolveWatchDb = (ref) => typeof ref.db === "function" ? ref.db() : ref.db;
611
+ var normalizeWatchId = (id) => {
612
+ if (typeof id === "string") return id;
613
+ if (typeof id === "number" || typeof id === "bigint") return String(id);
614
+ if (id instanceof Date) return `d${id.getTime()}`;
615
+ const hex = typeof id === "object" && id !== null ? id.toHexString : void 0;
616
+ if (typeof hex === "function") return hex.call(id);
617
+ return stableStringify(id);
618
+ };
619
+
620
+ // 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
+ );
468
631
 
469
632
  // src/cache/hot-cache/watcher.ts
633
+ var resolveDbSafe = (ref, logger) => {
634
+ try {
635
+ return resolveWatchDb(ref);
636
+ } catch (err) {
637
+ logger.debug?.(
638
+ { error: err instanceof Error ? err.message : String(err), collection: ref.collection },
639
+ "hot cache: watch db accessor not ready yet"
640
+ );
641
+ return null;
642
+ }
643
+ };
644
+ var documentKeyOf = (change) => {
645
+ const key = change?.documentKey;
646
+ return key ? key._id : void 0;
647
+ };
470
648
  var WatchCoordinator = class {
471
649
  constructor(host) {
472
650
  this.host = host;
@@ -474,6 +652,8 @@ var WatchCoordinator = class {
474
652
  streams = /* @__PURE__ */ new Map();
475
653
  failedStreams = /* @__PURE__ */ new Set();
476
654
  stopped = true;
655
+ /** Guards against stacking reconnect timers when several accessors lag. */
656
+ reopenTimer;
477
657
  /** Number of currently open change streams. */
478
658
  get size() {
479
659
  return this.streams.size;
@@ -486,6 +666,7 @@ var WatchCoordinator = class {
486
666
  /** Close every watcher and clear failure bookkeeping (idempotent). */
487
667
  async stop() {
488
668
  this.stopped = true;
669
+ if (this.reopenTimer) clearTimeout(this.reopenTimer);
489
670
  const closes = [...this.streams.values()].map((stream) => {
490
671
  try {
491
672
  return Promise.resolve(stream.close()).catch(() => {
@@ -500,14 +681,32 @@ var WatchCoordinator = class {
500
681
  }
501
682
  openWatchStreams() {
502
683
  const seen = /* @__PURE__ */ new Set();
684
+ let deferred = false;
503
685
  for (const q of this.host.queries().values()) {
504
686
  for (const ref of q.config.watch ?? []) {
505
- const key = `${ref.db.databaseName}${WATCH_SEP}${ref.collection}`;
687
+ const db = resolveDbSafe(ref, this.host.logger);
688
+ if (!db) {
689
+ deferred = true;
690
+ continue;
691
+ }
692
+ const key = `${db.databaseName}${WATCH_SEP}${ref.collection}`;
506
693
  if (seen.has(key) || this.streams.has(key)) continue;
507
694
  seen.add(key);
508
- void this.watchLoop(key, ref);
695
+ void this.watchLoop(key, ref).catch((err) => {
696
+ this.host.logger.error?.(
697
+ { error: err instanceof Error ? err.message : String(err), collection: ref.collection },
698
+ "hot cache: watcher crashed"
699
+ );
700
+ });
509
701
  }
510
702
  }
703
+ if (deferred && !this.stopped && !this.reopenTimer) {
704
+ this.reopenTimer = setTimeout(() => {
705
+ this.reopenTimer = void 0;
706
+ if (!this.stopped) this.openWatchStreams();
707
+ }, 5e3);
708
+ this.reopenTimer.unref?.();
709
+ }
511
710
  }
512
711
  /**
513
712
  * Long-lived change-stream consumer for one collection. Each data change
@@ -521,11 +720,19 @@ var WatchCoordinator = class {
521
720
  async watchLoop(key, ref) {
522
721
  let backoff = 0;
523
722
  while (!this.stopped) {
723
+ let connectedAt = Date.now();
524
724
  try {
525
- const stream = ref.db.collection(ref.collection).watch([]);
526
- stream.on("error", () => {
725
+ const db = resolveDbSafe(ref, this.host.logger);
726
+ if (!db) throw new Error("watch db accessor not available");
727
+ const stream = db.collection(ref.collection).watch([]);
728
+ stream.on("error", (err) => {
729
+ this.host.logger.debug?.(
730
+ { error: err instanceof Error ? err.message : String(err) },
731
+ "hot cache change stream error event"
732
+ );
527
733
  });
528
734
  this.streams.set(key, stream);
735
+ connectedAt = Date.now();
529
736
  if (backoff > 0) {
530
737
  this.host.logger.warn?.(
531
738
  { collection: ref.collection },
@@ -534,10 +741,13 @@ var WatchCoordinator = class {
534
741
  this.host.invalidateCollection(ref.collection);
535
742
  }
536
743
  const iterator = stream[Symbol.asyncIterator]();
537
- for await (const _change of iterator) {
538
- this.host.invalidateCollection(ref.collection);
744
+ for await (const change of iterator) {
745
+ const docId = documentKeyOf(change);
746
+ if (docId === void 0) this.host.invalidateCollection(ref.collection);
747
+ else this.host.invalidateDocument(ref.collection, docId);
539
748
  }
540
749
  } catch (err) {
750
+ if (Date.now() - connectedAt > 6e4) backoff = 0;
541
751
  const message = err instanceof Error ? err.message : String(err);
542
752
  this.host.logger.warn?.(
543
753
  { error: message, collection: ref.collection },
@@ -551,14 +761,14 @@ var WatchCoordinator = class {
551
761
  } catch {
552
762
  }
553
763
  }
554
- if (/only supported on replica sets/i.test(message)) {
764
+ if (isPermanentWatchError(message)) {
555
765
  this.markStreamFailed(key);
556
766
  return;
557
767
  }
558
768
  }
559
769
  if (this.stopped) return;
560
770
  backoff = Math.min(backoff + 1e3, 5e3);
561
- await sleepJittered(backoff);
771
+ await sleepJittered(backoff, 1e3, true);
562
772
  }
563
773
  }
564
774
  markStreamFailed(key) {
@@ -574,7 +784,9 @@ var WatchCoordinator = class {
574
784
  const set = /* @__PURE__ */ new Set();
575
785
  for (const q of this.host.queries().values()) {
576
786
  for (const ref of q.config.watch ?? []) {
577
- set.add(`${ref.db.databaseName}${WATCH_SEP}${ref.collection}`);
787
+ const db = resolveDbSafe(ref, this.host.logger);
788
+ if (!db) continue;
789
+ set.add(`${db.databaseName}${WATCH_SEP}${ref.collection}`);
578
790
  }
579
791
  }
580
792
  return set;
@@ -615,10 +827,14 @@ var HotCache = class {
615
827
  this.watcher = new WatchCoordinator({
616
828
  queries: () => this.queries,
617
829
  invalidateCollection: (collection) => this.invalidateCollection(collection),
830
+ invalidateDocument: (collection, id) => this.invalidateIds(collection, [id]),
618
831
  isReplica: () => this._mode === "replica",
619
832
  fallbackToStandalone: () => {
620
833
  this._mode = "standalone";
621
- if (this.refreshEnabled) this.ticker.start();
834
+ if (this.refreshEnabled && !this.ticker.running) {
835
+ this.ticker.start();
836
+ this.ticker.warnUnboundedStaleness();
837
+ }
622
838
  },
623
839
  logger: this.logger
624
840
  });
@@ -660,26 +876,27 @@ var HotCache = class {
660
876
  ttlMs: config.ttlMs ?? this.options.defaultTtlMs ?? 0,
661
877
  refreshIntervalMs: config.refreshIntervalMs ?? 0,
662
878
  clone: config.clone ?? false,
663
- maxValueBytes: config.maxValueBytes,
879
+ ...config.maxValueBytes !== void 0 ? { maxValueBytes: config.maxValueBytes } : {},
880
+ hasIdWatch: (config.watch ?? []).some((ref) => typeof ref.idsOf === "function"),
664
881
  gen: 0,
665
882
  hits: 0,
666
883
  misses: 0,
667
884
  refreshes: 0,
668
885
  loadErrors: 0,
669
886
  sizeSkips: 0,
887
+ idDrops: 0,
670
888
  warnedSize: false
671
889
  };
672
890
  this.queries.set(name, query);
673
891
  if (this._mode === "replica" && !this.stopped) {
674
892
  this.watcher.start();
675
893
  }
676
- return {
677
- get: (...args) => this.get(name, ...args),
678
- invalidate: (...args) => {
679
- if (args.length === 0) this.invalidate(name);
680
- else this.invalidateParams(name, ...args);
681
- }
894
+ const get = (...args) => this.get(name, ...args);
895
+ const invalidate = (...args) => {
896
+ if (args.length === 0) this.invalidate(name);
897
+ else this.invalidateParams(name, ...args);
682
898
  };
899
+ return { get, invalidate };
683
900
  }
684
901
  /**
685
902
  * Read-through read by dynamic name lookup. Call parameters are hashed into
@@ -701,7 +918,7 @@ var HotCache = class {
701
918
  q.lru.delete(key);
702
919
  }
703
920
  q.misses++;
704
- return this.fetch(q, key, args, q.gen);
921
+ return await this.fetch(q, key, args, q.gen);
705
922
  }
706
923
  /** Drop every cached entry for a registered query. */
707
924
  invalidate(name) {
@@ -734,6 +951,48 @@ var HotCache = class {
734
951
  }
735
952
  }
736
953
  }
954
+ /**
955
+ * Fine-tuned purge: drop only the entries that depend on specific document
956
+ * ids of a watched collection. Two complementary mechanisms feed it:
957
+ *
958
+ * - **Replica mode** — the change stream extracts `documentKey._id` from
959
+ * each event and calls this with just that id, so a write to one document
960
+ * no longer purges every query bound to the whole collection.
961
+ * - **Manual (any mode)** — call it from ORM after-write hooks or anywhere
962
+ * else you know exactly which ids changed.
963
+ *
964
+ * Per query bound to `collection`, semantics are:
965
+ * - watch ref declares `idsOf` → only LRU entries whose extracted ids
966
+ * include one of `ids` are dropped (`depsAll` entries — where extraction
967
+ * failed — are dropped too; conservative). Sibling entries stay warm and
968
+ * `stats().perQuery[name].idDrops` counts what was dropped.
969
+ * - no `idsOf` on any bound ref → full purge of that query (the
970
+ * `invalidateCollection` behavior — the safe default).
971
+ */
972
+ invalidateIds(collection, ids) {
973
+ if (ids.length === 0) return;
974
+ const changed = /* @__PURE__ */ new Set();
975
+ for (const id of ids) changed.add(`${collection}${WATCH_SEP}${normalizeWatchId(id)}`);
976
+ for (const q of this.queries.values()) {
977
+ const bound = q.config.watch?.filter((ref) => ref.collection === collection);
978
+ if (!bound || bound.length === 0) continue;
979
+ if (!bound.some((ref) => typeof ref.idsOf === "function")) {
980
+ q.lru.clear();
981
+ q.gen++;
982
+ continue;
983
+ }
984
+ let dropped = 0;
985
+ for (const [key, entry] of [...q.lru.entries()]) {
986
+ const matches = entry.depsAll === true || entry.deps?.some((dep) => changed.has(dep)) === true;
987
+ if (matches) {
988
+ q.lru.delete(key);
989
+ dropped++;
990
+ }
991
+ }
992
+ q.idDrops += dropped;
993
+ q.gen++;
994
+ }
995
+ }
737
996
  /** Drop every cached entry for every query. */
738
997
  clear() {
739
998
  for (const q of this.queries.values()) {
@@ -743,9 +1002,10 @@ var HotCache = class {
743
1002
  }
744
1003
  /**
745
1004
  * Runtime observability snapshot: total queries/entries plus per-query
746
- * counters (`hits`, `misses`, background `refreshes`, `loadErrors`, and
747
- * `sizeSkips` for entries dropped by `maxValueBytes`). Useful for dashboards
748
- * and cache-effectiveness checks.
1005
+ * counters (`hits`, `misses`, background `refreshes`, `loadErrors`,
1006
+ * `sizeSkips` for entries dropped by `maxValueBytes`, and `idDrops` for
1007
+ * entries dropped by id-targeted invalidation). Useful for dashboards and
1008
+ * cache-effectiveness checks.
749
1009
  */
750
1010
  stats() {
751
1011
  const perQuery = {};
@@ -757,6 +1017,7 @@ var HotCache = class {
757
1017
  refreshes: q.refreshes,
758
1018
  loadErrors: q.loadErrors,
759
1019
  sizeSkips: q.sizeSkips,
1020
+ idDrops: q.idDrops,
760
1021
  evictions: q.lru.evictions
761
1022
  };
762
1023
  }
@@ -765,12 +1026,15 @@ var HotCache = class {
765
1026
  /**
766
1027
  * Probe replica support once, then start change-stream watchers (replica) or
767
1028
  * the global refresh ticker (standalone). Idempotent — resolves the mode.
1029
+ * A FAILED start does not stick: the memoized promise is cleared so the next
1030
+ * `get()`/`start()` retries probing (one transient blip during startup must
1031
+ * not disable background freshness for the life of the instance).
768
1032
  */
769
1033
  async start() {
770
1034
  if (this.disposed) return this._mode;
771
1035
  if (this.started) return this._mode;
772
1036
  if (!this.startPromise) {
773
- this.startPromise = this.runStart();
1037
+ this.startPromise = this.runStart().catch(this.forgetFailedStart);
774
1038
  }
775
1039
  return this.startPromise;
776
1040
  }
@@ -792,6 +1056,17 @@ var HotCache = class {
792
1056
  this._mode = "unknown";
793
1057
  }
794
1058
  /* ----------------------------- internals ----------------------------- */
1059
+ /**
1060
+ * A FAILED start does not stick: clear the memoized state so the next
1061
+ * `get()`/`start()` retries probing (one transient blip during startup must
1062
+ * not disable background freshness for the life of the instance).
1063
+ */
1064
+ forgetFailedStart = (err) => {
1065
+ this.startPromise = void 0;
1066
+ this.startAttempted = false;
1067
+ this.started = false;
1068
+ throw err;
1069
+ };
795
1070
  async runStart() {
796
1071
  this.stopped = false;
797
1072
  const supported = this.options.mode === "replica" ? true : this.options.mode === "standalone" ? false : await (this.options.probe ?? this.defaultProbe)();
@@ -815,20 +1090,34 @@ var HotCache = class {
815
1090
  ensureStarted() {
816
1091
  if (this.disposed || this.started || this.startAttempted) return;
817
1092
  this.startAttempted = true;
818
- void this.start().catch((err) => {
1093
+ const origin = new Error("hot cache: background start() was triggered by this call");
1094
+ const reportStartFailure = (err) => {
1095
+ const wrapped = err instanceof Error ? err : new Error(String(err));
1096
+ if (wrapped.cause === void 0) wrapped.cause = origin;
819
1097
  this.logger.warn?.(
820
- { error: err instanceof Error ? err.message : String(err) },
1098
+ { error: wrapped.message, stack: wrapped.stack, trigger: origin.stack },
821
1099
  "hot cache start failed; reads continue without auto-refresh"
822
1100
  );
823
- });
1101
+ };
1102
+ this.start().catch(reportStartFailure);
824
1103
  }
825
1104
  defaultProbe = async () => {
826
1105
  for (const q of this.queries.values()) {
827
1106
  for (const ref of q.config.watch ?? []) {
828
- const caps = await probeMongoCapabilities(ref.db);
1107
+ let db;
1108
+ try {
1109
+ db = resolveWatchDb(ref);
1110
+ } catch {
1111
+ this.logger.warn?.(
1112
+ {},
1113
+ "hot cache: replica probe could not resolve the watch db (accessor threw) \u2014 falling back to the standalone ticker; pass `mode` or a custom `probe` to pin the behavior"
1114
+ );
1115
+ return false;
1116
+ }
1117
+ const caps = await probeMongoCapabilities(db);
829
1118
  if (!caps.probed) {
830
1119
  this.logger.warn?.(
831
- { db: ref.db.databaseName },
1120
+ { db: db.databaseName },
832
1121
  "hot cache: replica probe returned no result (timeout?) \u2014 falling back to the standalone ticker; pass `mode` or a custom `probe` to pin the behavior"
833
1122
  );
834
1123
  return false;
@@ -839,17 +1128,18 @@ var HotCache = class {
839
1128
  return false;
840
1129
  };
841
1130
  fetch(q, key, args, gen) {
842
- const dedupeKey = `${q.name}${WATCH_SEP}${key}`;
843
- return this.inflight.run(dedupeKey, async () => {
1131
+ const dedupeKey = `${q.name}${WATCH_SEP}g${gen}${WATCH_SEP}${key}`;
1132
+ const loadOnce = async () => {
844
1133
  try {
845
1134
  const value = await q.config.loader(...args);
846
1135
  if (q.gen === gen) {
847
- if (q.maxValueBytes !== void 0 && estimateSize(value) > q.maxValueBytes) {
1136
+ const bytes = q.maxValueBytes !== void 0 ? estimateSize(value) : void 0;
1137
+ if (bytes !== void 0 && bytes > q.maxValueBytes) {
848
1138
  q.sizeSkips++;
849
1139
  if (!q.warnedSize) {
850
1140
  q.warnedSize = true;
851
1141
  this.logger.warn?.(
852
- { query: q.name, bytes: estimateSize(value), max: q.maxValueBytes },
1142
+ { query: q.name, bytes, max: q.maxValueBytes },
853
1143
  "hot cache: value exceeds maxValueBytes \u2014 returned but not cached"
854
1144
  );
855
1145
  }
@@ -862,17 +1152,42 @@ var HotCache = class {
862
1152
  q.loadErrors++;
863
1153
  throw err;
864
1154
  }
865
- });
1155
+ };
1156
+ return this.inflight.run(dedupeKey, loadOnce);
866
1157
  }
867
1158
  setEntry(q, key, args, value) {
868
1159
  const now = Date.now();
1160
+ const deps2 = q.hasIdWatch ? this.computeDeps(q, args) : {};
869
1161
  q.lru.set(key, {
870
1162
  value,
871
1163
  args,
872
1164
  expiresAt: q.ttlMs > 0 ? now + q.ttlMs : Infinity,
873
- nextRefreshAt: q.refreshIntervalMs > 0 ? now + q.refreshIntervalMs : Infinity
1165
+ nextRefreshAt: q.refreshIntervalMs > 0 ? now + q.refreshIntervalMs : Infinity,
1166
+ ...deps2
874
1167
  });
875
1168
  }
1169
+ /**
1170
+ * Extract the watched-document dependencies of one loader call. Namespaced by
1171
+ * collection so multi-collection watchers never cross-invalidate. An extractor
1172
+ * that throws or returns a non-array yields `{ depsAll: true }` — the entry is
1173
+ * then dropped by ANY targeted change on its watched collections instead of
1174
+ * being incorrectly kept.
1175
+ */
1176
+ computeDeps(q, args) {
1177
+ const deps2 = [];
1178
+ for (const ref of q.config.watch ?? []) {
1179
+ if (!ref.idsOf) continue;
1180
+ const extract = ref.idsOf;
1181
+ try {
1182
+ const ids = extract(...args);
1183
+ if (!Array.isArray(ids)) return { depsAll: true };
1184
+ for (const id of ids) deps2.push(`${ref.collection}${WATCH_SEP}${normalizeWatchId(id)}`);
1185
+ } catch {
1186
+ return { depsAll: true };
1187
+ }
1188
+ }
1189
+ return { deps: deps2 };
1190
+ }
876
1191
  };
877
1192
  var createHotCache = (options) => new HotCache(options);
878
1193
 
@@ -883,6 +1198,12 @@ var cacheCollectionKey = (dbName, physical) => `${dbName}${NS_SEP}${physical}`;
883
1198
  var QueryCache = class {
884
1199
  lru;
885
1200
  index = /* @__PURE__ */ new Map();
1201
+ /**
1202
+ * Reverse index: key → every collection it is registered under. Without it,
1203
+ * keys dropped by TTL expiry / capacity eviction / version-guard would stay
1204
+ * listed in `index` forever (unbounded growth + O(dead) invalidation scans).
1205
+ */
1206
+ keyCollections = /* @__PURE__ */ new Map();
886
1207
  /** Per-collection version — bumped on every invalidation/clear of that collection. */
887
1208
  colVersions = /* @__PURE__ */ new Map();
888
1209
  ttlMs;
@@ -894,7 +1215,10 @@ var QueryCache = class {
894
1215
  invalidateEvents = 0;
895
1216
  clearEvents = 0;
896
1217
  constructor(options = {}) {
897
- this.lru = new LRU({ max: options.maxSize ?? 500 });
1218
+ this.lru = new LRU({
1219
+ max: options.maxSize ?? 500,
1220
+ onEvict: (key) => this.unregisterKey(key)
1221
+ });
898
1222
  this.ttlMs = options.ttlMs ?? 0;
899
1223
  this.clone = options.clone ?? false;
900
1224
  }
@@ -934,14 +1258,14 @@ var QueryCache = class {
934
1258
  return void 0;
935
1259
  }
936
1260
  if (this.ttlMs > 0 && Date.now() > entry.expiresAt) {
937
- this.lru.delete(key);
1261
+ this.dropKey(key);
938
1262
  this.misses++;
939
1263
  return void 0;
940
1264
  }
941
1265
  if (entry.versions) {
942
1266
  for (const [col, ver] of Object.entries(entry.versions)) {
943
1267
  if (this.versionOf(col) !== ver) {
944
- this.lru.delete(key);
1268
+ this.dropKey(key);
945
1269
  this.misses++;
946
1270
  return void 0;
947
1271
  }
@@ -950,14 +1274,36 @@ var QueryCache = class {
950
1274
  this.hits++;
951
1275
  return this.clone ? cloneDeep(entry.value) : entry.value;
952
1276
  }
1277
+ /** Remove a key from the LRU and every collection index that references it. */
1278
+ dropKey(key) {
1279
+ this.lru.delete(key);
1280
+ this.unregisterKey(key);
1281
+ }
1282
+ /** Remove `key` from `index` (called for evictions AND explicit drops). */
1283
+ unregisterKey(key) {
1284
+ const collections = this.keyCollections.get(key);
1285
+ if (!collections) return;
1286
+ this.keyCollections.delete(key);
1287
+ for (const collection of collections) {
1288
+ const keys = this.index.get(collection);
1289
+ if (!keys) continue;
1290
+ keys.delete(key);
1291
+ if (keys.size === 0) this.index.delete(collection);
1292
+ }
1293
+ }
953
1294
  set(key, value, ttlMs, collections, versions) {
954
1295
  this.sets++;
955
1296
  const ttl = ttlMs ?? this.ttlMs;
956
1297
  const expiresAt = ttl > 0 ? Date.now() + ttl : Infinity;
957
- this.lru.set(key, { value, expiresAt, versions });
1298
+ this.lru.set(key, {
1299
+ value,
1300
+ expiresAt,
1301
+ ...versions !== void 0 ? { versions } : {}
1302
+ });
958
1303
  const sep = key.indexOf(SEP);
959
1304
  const primary = sep === -1 ? key : key.slice(0, sep);
960
1305
  const collectionKeys = /* @__PURE__ */ new Set([primary, ...collections ?? []]);
1306
+ this.keyCollections.set(key, collectionKeys);
961
1307
  for (const collection of collectionKeys) {
962
1308
  let keys = this.index.get(collection);
963
1309
  if (!keys) {
@@ -969,7 +1315,7 @@ var QueryCache = class {
969
1315
  }
970
1316
  delete(key) {
971
1317
  this.deletes++;
972
- this.lru.delete(key);
1318
+ this.dropKey(key);
973
1319
  }
974
1320
  /** Drop every cached entry belonging to a physical collection. */
975
1321
  invalidateByCollection(collection) {
@@ -977,7 +1323,7 @@ var QueryCache = class {
977
1323
  const keys = this.index.get(collection);
978
1324
  if (!keys) return;
979
1325
  this.invalidateEvents++;
980
- for (const key of keys) this.lru.delete(key);
1326
+ for (const key of [...keys]) this.dropKey(key);
981
1327
  this.index.delete(collection);
982
1328
  }
983
1329
  clear() {
@@ -987,19 +1333,35 @@ var QueryCache = class {
987
1333
  }
988
1334
  this.lru.clear();
989
1335
  this.index.clear();
1336
+ this.keyCollections.clear();
990
1337
  }
991
1338
  };
992
1339
 
993
1340
  // src/graceful-transaction.ts
994
1341
  var withGracefulMongoTransaction = async (runner, fn, options = {}) => {
995
- const enabled = options.capabilities === void 0 || mongoTransactionsEnabled(options.capabilities, { urlHint: options.urlHint });
1342
+ let enabled = true;
1343
+ if (options.capabilities !== void 0) {
1344
+ const env = readMongoTransactionsEnvOverride();
1345
+ if (env !== void 0) {
1346
+ enabled = env;
1347
+ } else {
1348
+ const state = options.capabilities.get();
1349
+ if (state.probed) {
1350
+ enabled = state.transactionsSupported;
1351
+ } else if (options.urlHint) {
1352
+ enabled = /replicaSet=|mongodb\+srv:/i.test(options.urlHint);
1353
+ }
1354
+ }
1355
+ }
996
1356
  if (!enabled) return fn(null);
997
1357
  try {
998
1358
  return await runner.transaction(fn);
999
1359
  } catch (err) {
1000
- const message = err instanceof Error ? err.message : "";
1001
- if (/Transaction numbers are only allowed|transactions are not supported/i.test(message)) {
1002
- options.logger?.warn?.({ error: message }, "transactions unsupported, falling back");
1360
+ if (isTransactionUnsupportedError(err)) {
1361
+ options.logger?.warn?.(
1362
+ { error: err instanceof Error ? err.message : String(err) },
1363
+ "transactions unsupported, falling back"
1364
+ );
1003
1365
  return fn(null);
1004
1366
  }
1005
1367
  if (options.wrapMongoErrors !== false) {
@@ -1098,13 +1460,17 @@ var DataLoader = class {
1098
1460
  }
1099
1461
  }
1100
1462
  async flush() {
1101
- if (this.flushing) return;
1463
+ if (this.flushing) {
1464
+ this.scheduled = false;
1465
+ return;
1466
+ }
1102
1467
  if (this.queue.length === 0) {
1103
1468
  this.scheduled = false;
1104
1469
  return;
1105
1470
  }
1106
1471
  const items = this.queue.splice(0, this.maxBatchSize);
1107
1472
  this.flushing = true;
1473
+ this.scheduled = false;
1108
1474
  try {
1109
1475
  const resultMap = await this.batchFn(items.map((item) => item.key));
1110
1476
  for (const item of items) {
@@ -1119,12 +1485,8 @@ var DataLoader = class {
1119
1485
  }
1120
1486
  } finally {
1121
1487
  this.flushing = false;
1122
- this.scheduled = false;
1123
- if (this.queue.length > 0) {
1124
- this.scheduled = true;
1125
- queueMicrotask(() => {
1126
- void this.flush();
1127
- });
1488
+ if (!this.scheduled && this.queue.length > 0) {
1489
+ this.scheduleFlush();
1128
1490
  }
1129
1491
  }
1130
1492
  }
@@ -1135,6 +1497,7 @@ var canonicalKey = (value) => {
1135
1497
  const anyValue = value;
1136
1498
  if (typeof anyValue.toHexString === "function") return `oid:${anyValue.toHexString()}`;
1137
1499
  if (value instanceof Date) return `date:${value.getTime()}`;
1500
+ return `obj:${stableStringify(value)}`;
1138
1501
  }
1139
1502
  return `${typeof value}:${String(value)}`;
1140
1503
  };
@@ -1145,13 +1508,14 @@ var JOURNAL_COLLECTION = "_migrations";
1145
1508
 
1146
1509
  // src/migrations/files.ts
1147
1510
  var migrationNameOf = (file) => basename(file).replace(/\.(ts|js|mjs)$/, "");
1511
+ var numericPrefixOf = (file) => Number(MIGRATION_FILE.exec(basename(file))?.[1] ?? Number.MAX_SAFE_INTEGER);
1148
1512
  var listMigrationFiles = async (dir) => {
1149
1513
  const files = await readdir(dir);
1150
- return files.filter((file) => MIGRATION_FILE.test(file)).sort();
1514
+ return files.filter((file) => MIGRATION_FILE.test(file)).sort((a, b) => numericPrefixOf(a) - numericPrefixOf(b) || a.localeCompare(b));
1151
1515
  };
1152
1516
  var loadMigration = async (dir, file) => {
1153
1517
  const mod = await import(pathToFileURL(join(dir, file)).href);
1154
- return { name: migrationNameOf(file), ...mod };
1518
+ return { ...mod, name: migrationNameOf(file) };
1155
1519
  };
1156
1520
  var ensureMigrationDir = async (dir) => {
1157
1521
  await mkdir(dir, { recursive: true });
@@ -1165,45 +1529,78 @@ var nextMigrationNumber = async (dir) => {
1165
1529
  };
1166
1530
 
1167
1531
  // src/migrations/journal.ts
1532
+ var isDuplicateKeyError = (err) => {
1533
+ const code = err?.code;
1534
+ if (code === 11e3 || code === 11001) return true;
1535
+ return /E11000 duplicate key/.test(String(err?.message ?? ""));
1536
+ };
1168
1537
  var createMigrationJournal = (manager) => {
1538
+ let indexReady = false;
1539
+ const ensureIndex = async () => {
1540
+ if (indexReady) return;
1541
+ try {
1542
+ await manager.createIndex?.(JOURNAL_COLLECTION, { name: 1 }, { unique: true });
1543
+ } catch {
1544
+ }
1545
+ indexReady = true;
1546
+ };
1169
1547
  const appliedNames = async () => {
1170
1548
  const rows = await manager.findMany(JOURNAL_COLLECTION);
1171
1549
  return new Set(
1172
1550
  rows.filter((row) => row.status === void 0 || row.status === "applied").map((row) => row.name)
1173
1551
  );
1174
1552
  };
1175
- const cleanupStale = async () => {
1176
- await manager.deleteMany(JOURNAL_COLLECTION, {
1177
- status: "running",
1178
- appliedAt: { $lt: new Date(Date.now() - 6e4) }
1179
- }).catch(() => {
1180
- });
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
+ const claim = async (name, file, leaseMs) => {
1563
+ await ensureIndex();
1564
+ const now = /* @__PURE__ */ new Date();
1565
+ const expiresAt = new Date(Date.now() + leaseMs);
1566
+ const stole = await manager.findOneAndUpdate(
1567
+ JOURNAL_COLLECTION,
1568
+ stealableFilter(name, now, new Date(Date.now() - leaseMs)),
1569
+ { $set: { status: "running", appliedAt: now, file, expiresAt } },
1570
+ { returnDocument: "before" }
1571
+ );
1572
+ if (stole !== null) return true;
1573
+ try {
1574
+ await manager.insertOne(JOURNAL_COLLECTION, {
1575
+ name,
1576
+ appliedAt: now,
1577
+ status: "running",
1578
+ file,
1579
+ expiresAt
1580
+ });
1581
+ return true;
1582
+ } catch (err) {
1583
+ if (isDuplicateKeyError(err)) return false;
1584
+ throw err;
1585
+ }
1181
1586
  };
1182
- const claim = async (name, file) => {
1183
- const claimed = await manager.findOneAndUpdate(
1587
+ const renew = async (name, leaseMs) => {
1588
+ await manager.updateOne(
1184
1589
  JOURNAL_COLLECTION,
1185
- { name },
1186
- {
1187
- $setOnInsert: {
1188
- name,
1189
- appliedAt: /* @__PURE__ */ new Date(),
1190
- status: "running",
1191
- file
1192
- }
1193
- },
1194
- { upsert: true, returnDocument: "before" }
1590
+ { name, status: "running" },
1591
+ { $set: { expiresAt: new Date(Date.now() + leaseMs) } }
1195
1592
  );
1196
- return claimed === null;
1197
1593
  };
1198
1594
  const markApplied = async (name) => {
1199
- await manager.updateOne(JOURNAL_COLLECTION, { name, status: "running" }, { $set: { status: "applied" } }).catch(async () => {
1200
- await manager.insertOne(JOURNAL_COLLECTION, {
1201
- name,
1202
- appliedAt: /* @__PURE__ */ new Date(),
1203
- status: "applied"
1204
- }).catch(() => {
1595
+ const res = await manager.updateOne(
1596
+ JOURNAL_COLLECTION,
1597
+ { name, status: "running" },
1598
+ { $set: { status: "applied" } }
1599
+ );
1600
+ if (res?.matchedCount === 0) {
1601
+ await manager.insertOne(JOURNAL_COLLECTION, { name, appliedAt: /* @__PURE__ */ new Date(), status: "applied" }).catch(() => {
1205
1602
  });
1206
- });
1603
+ }
1207
1604
  };
1208
1605
  const removeRunning = async (name) => {
1209
1606
  await manager.deleteOne(JOURNAL_COLLECTION, { name, status: "running" }).catch(() => {
@@ -1212,45 +1609,76 @@ var createMigrationJournal = (manager) => {
1212
1609
  const unrecord = async (name) => {
1213
1610
  await manager.deleteOne(JOURNAL_COLLECTION, { name });
1214
1611
  };
1215
- return { appliedNames, cleanupStale, claim, markApplied, removeRunning, unrecord };
1612
+ return { appliedNames, claim, renew, markApplied, removeRunning, unrecord };
1216
1613
  };
1217
1614
 
1218
1615
  // src/migrations/index.ts
1616
+ var DEFAULT_LEASE_MS = 12e4;
1219
1617
  var createMongoMigrationRunner = (service, options = {}) => {
1220
1618
  const migrationDir = options.migrationDir ?? service.config?.migrationDir ?? "./migrations";
1221
1619
  const logger = options.logger ?? service.logger;
1620
+ const leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;
1222
1621
  const manager = () => {
1223
- const key = Object.keys(service.db)[0];
1622
+ const keys = Object.keys(service.db);
1623
+ const key = options.db !== void 0 ? options.db : keys[0];
1224
1624
  const db = key ? service.db[key] : void 0;
1225
- if (!db) throw new Error("No database client connected \u2014 call service.makeConnections() first");
1625
+ if (!db) {
1626
+ throw new Error(
1627
+ options.db !== void 0 && !keys.includes(options.db) ? `Unknown database "${options.db}" \u2014 connected: ${keys.join(", ")}` : "No database client connected \u2014 call service.makeConnections() first"
1628
+ );
1629
+ }
1226
1630
  return db;
1227
1631
  };
1632
+ const startHeartbeat = (journal, name) => {
1633
+ const timer = setInterval(
1634
+ () => {
1635
+ void journal.renew(name, leaseMs).catch(() => {
1636
+ });
1637
+ },
1638
+ Math.max(leaseMs / 2, 1e3)
1639
+ );
1640
+ timer.unref?.();
1641
+ return () => clearInterval(timer);
1642
+ };
1228
1643
  const up = async () => {
1229
1644
  await ensureMigrationDir(migrationDir);
1230
1645
  const journal = createMigrationJournal(manager());
1231
- await journal.cleanupStale();
1232
1646
  const files = await listMigrationFiles(migrationDir);
1233
1647
  const applied = await journal.appliedNames();
1234
1648
  const pending = files.filter((file) => !applied.has(migrationNameOf(file)));
1235
1649
  for (const file of pending) {
1236
1650
  const migration = await loadMigration(migrationDir, file);
1237
1651
  logger.info({ name: migration.name }, "running migration up");
1238
- if (!await journal.claim(migration.name, basename(file))) {
1239
- logger.info({ name: migration.name }, "migration already applied, skipping");
1652
+ if (!await journal.claim(migration.name, basename(file), leaseMs)) {
1653
+ logger.info(
1654
+ { name: migration.name },
1655
+ "migration claimed by another runner or already applied, skipping"
1656
+ );
1240
1657
  continue;
1241
1658
  }
1659
+ const stopHeartbeat = startHeartbeat(journal, migration.name);
1242
1660
  try {
1243
1661
  await migration.up({ service, logger, name: migration.name });
1244
1662
  await journal.markApplied(migration.name);
1245
1663
  } catch (err) {
1246
1664
  await journal.removeRunning(migration.name);
1247
1665
  throw err;
1666
+ } finally {
1667
+ stopHeartbeat();
1248
1668
  }
1249
1669
  logger.info({ name: migration.name }, "migration applied");
1250
1670
  }
1251
1671
  };
1252
1672
  const down = async (targetName) => {
1253
1673
  const files = await listMigrationFiles(migrationDir);
1674
+ if (targetName !== void 0) {
1675
+ const known = files.some((file) => migrationNameOf(file) === targetName);
1676
+ if (!known) {
1677
+ throw new Error(
1678
+ `down(): unknown migration "${targetName}" \u2014 known: ${files.map(migrationNameOf).join(", ") || "(none)"}`
1679
+ );
1680
+ }
1681
+ }
1254
1682
  const journal = createMigrationJournal(manager());
1255
1683
  const applied = await journal.appliedNames();
1256
1684
  const appliedFiles = files.filter((file) => applied.has(migrationNameOf(file))).reverse();
@@ -1260,7 +1688,7 @@ var createMongoMigrationRunner = (service, options = {}) => {
1260
1688
  await migration.down({ service, logger, name: migration.name });
1261
1689
  await journal.unrecord(migration.name);
1262
1690
  logger.info({ name: migration.name }, "migration rolled back");
1263
- if (targetName && migration.name === targetName) break;
1691
+ if (targetName !== void 0 && migration.name === targetName) break;
1264
1692
  }
1265
1693
  };
1266
1694
  const status = async () => {
@@ -1274,10 +1702,6 @@ var createMongoMigrationRunner = (service, options = {}) => {
1274
1702
  };
1275
1703
  const create = async (name) => {
1276
1704
  await ensureMigrationDir(migrationDir);
1277
- const next = await nextMigrationNumber(migrationDir);
1278
- const padded = String(next).padStart(3, "0");
1279
- const fileName = `${padded}_${name}.ts`;
1280
- const filePath = join(migrationDir, fileName);
1281
1705
  const template = `import type { MigrationContext } from '@ignex/ninox';
1282
1706
 
1283
1707
  export const up = async (ctx: MigrationContext): Promise<void> => {
@@ -1290,15 +1714,26 @@ export const down = async (ctx: MigrationContext): Promise<void> => {
1290
1714
  // await users.client.dropCollection('users');
1291
1715
  };
1292
1716
  `;
1293
- await writeFile(filePath, template, "utf8");
1294
- logger.info({ name, file: fileName }, "migration scaffolded");
1295
- return filePath;
1717
+ for (; ; ) {
1718
+ const next = await nextMigrationNumber(migrationDir);
1719
+ const padded = String(next).padStart(3, "0");
1720
+ const fileName = `${padded}_${name}.ts`;
1721
+ const filePath = join(migrationDir, fileName);
1722
+ try {
1723
+ await writeFile(filePath, template, { encoding: "utf8", flag: "wx" });
1724
+ logger.info({ name, file: fileName }, "migration scaffolded");
1725
+ return filePath;
1726
+ } catch (err) {
1727
+ if (err?.code === "EEXIST") continue;
1728
+ throw err;
1729
+ }
1730
+ }
1296
1731
  };
1297
1732
  return { up, down, status, create };
1298
1733
  };
1299
1734
 
1300
1735
  // src/mongo-helpers.ts
1301
- var withRetry = async (fn, options = {}, _ctx) => {
1736
+ var withRetry = async (fn, options = {}) => {
1302
1737
  const maxAttempts = Math.max(1, options.maxAttempts ?? 3);
1303
1738
  const delayMs = options.delayMs ?? 150;
1304
1739
  const logger = options.logger;
@@ -1311,7 +1746,8 @@ var withRetry = async (fn, options = {}, _ctx) => {
1311
1746
  lastError = err;
1312
1747
  attempt += 1;
1313
1748
  if (attempt >= maxAttempts || !isMongoTransientError(err)) throw err;
1314
- const backoff = delayMs * 2 ** (attempt - 1);
1749
+ const base = delayMs * 2 ** (attempt - 1);
1750
+ const backoff = Math.round(base * (0.8 + Math.random() * 0.4));
1315
1751
  logger?.debug?.({ attempt, backoffMs: backoff, error: err?.message }, "retrying");
1316
1752
  await sleep(backoff);
1317
1753
  }
@@ -1341,7 +1777,13 @@ var DEFAULT_FIND_LIMIT = 100;
1341
1777
  var MAX_BATCH_OPS = 1e5;
1342
1778
 
1343
1779
  // src/query-builder/query-builder.ts
1344
- var QueryBuilder = class {
1780
+ var mergeFilterKey = (prev, next) => {
1781
+ if (prev !== null && typeof prev === "object" && !Array.isArray(prev) && next !== null && typeof next === "object" && !Array.isArray(next)) {
1782
+ return { ...prev, ...next };
1783
+ }
1784
+ return next;
1785
+ };
1786
+ var QueryBuilder = class _QueryBuilder {
1345
1787
  constructor(ctx) {
1346
1788
  this.ctx = ctx;
1347
1789
  }
@@ -1354,77 +1796,112 @@ var QueryBuilder = class {
1354
1796
  get filter() {
1355
1797
  return this._filter;
1356
1798
  }
1357
- /** Shallow-merge a filter into the current one (later keys win). */
1799
+ /** Copy-on-write fork used by every chain method. */
1800
+ fork() {
1801
+ const next = new _QueryBuilder(this.ctx);
1802
+ next._filter = this._filter;
1803
+ if (this._sort !== void 0) next._sort = this._sort;
1804
+ if (this._skip !== void 0) next._skip = this._skip;
1805
+ if (this._limit !== void 0) next._limit = this._limit;
1806
+ if (this._projection !== void 0) next._projection = this._projection;
1807
+ next._options = { ...this._options };
1808
+ return next;
1809
+ }
1810
+ /** Merge a filter into the current one (operator docs deep-merge per key). */
1358
1811
  where(filter) {
1359
- this._filter = { ...this._filter, ...filter };
1360
- return this;
1812
+ const next = this.fork();
1813
+ const merged = { ...next._filter };
1814
+ for (const [key, value] of Object.entries(filter)) {
1815
+ merged[key] = key in merged ? mergeFilterKey(merged[key], value) : value;
1816
+ }
1817
+ next._filter = merged;
1818
+ return next;
1361
1819
  }
1362
- /** AND-combine the current filter with a new one. */
1820
+ /** AND-combine the current filter with a new one (stays flat across repeats). */
1363
1821
  and(filter) {
1364
- this._filter = { $and: [this._filter, filter] };
1365
- return this;
1822
+ const next = this.fork();
1823
+ const current = next._filter;
1824
+ const existing = Array.isArray(current.$and) ? current.$and : Object.keys(current).length > 0 ? [current] : [];
1825
+ next._filter = { $and: [...existing, filter] };
1826
+ return next;
1366
1827
  }
1367
- /** OR-combine: current filter AND (filter1 OR filter2 ...). */
1828
+ /**
1829
+ * OR-combine: current filter AND (filter1 OR filter2 ...). Requires at least
1830
+ * one filter — an empty `$or` is rejected by the server.
1831
+ */
1368
1832
  or(...filters) {
1369
- this._filter = { $and: [this._filter, { $or: filters }] };
1370
- return this;
1833
+ if (filters.length === 0) {
1834
+ throw new BadRequest("QueryBuilder.or() requires at least one filter");
1835
+ }
1836
+ return this.and(filters.length === 1 ? filters[0] : { $or: filters });
1371
1837
  }
1372
1838
  /** Set the sort order (`{ field: 1 | -1 }`). */
1373
1839
  sort(sort) {
1374
- this._sort = sort;
1375
- return this;
1840
+ const next = this.fork();
1841
+ next._sort = sort;
1842
+ return next;
1376
1843
  }
1377
1844
  /** Skip the first `n` matching documents. */
1378
1845
  skip(n) {
1379
- this._skip = n;
1380
- return this;
1846
+ const next = this.fork();
1847
+ next._skip = n;
1848
+ return next;
1381
1849
  }
1382
1850
  /** Limit the result set to `n` documents. */
1383
1851
  limit(n) {
1384
- this._limit = n;
1385
- return this;
1852
+ const next = this.fork();
1853
+ next._limit = n;
1854
+ return next;
1386
1855
  }
1387
1856
  /** Project only the given fields — the projection is pushed to the driver. */
1388
1857
  select(fields) {
1858
+ const next = this.fork();
1389
1859
  const projection = {};
1390
1860
  for (const field of fields) projection[field] = 1;
1391
- this._projection = projection;
1392
- return this;
1861
+ next._projection = projection;
1862
+ return next;
1393
1863
  }
1394
1864
  /** Raw projection document (`{ field: 1 }` / `{ field: 0 }`). */
1395
1865
  project(projection) {
1396
- this._projection = projection;
1397
- return this;
1866
+ const next = this.fork();
1867
+ next._projection = projection;
1868
+ return next;
1398
1869
  }
1399
1870
  /** Set the index hint. */
1400
1871
  hint(hint) {
1401
- this._options.hint = hint;
1402
- return this;
1872
+ const next = this.fork();
1873
+ next._options.hint = hint;
1874
+ return next;
1403
1875
  }
1404
1876
  /** Set the driver cursor batch size. */
1405
1877
  batchSize(n) {
1406
- this._options.batchSize = n;
1407
- return this;
1878
+ const next = this.fork();
1879
+ next._options.batchSize = n;
1880
+ return next;
1408
1881
  }
1409
1882
  /** Run the query inside a transaction session. */
1410
1883
  session(session) {
1411
- this._options.session = session;
1412
- return this;
1884
+ const next = this.fork();
1885
+ next._options.session = session;
1886
+ return next;
1413
1887
  }
1414
1888
  /** Cap server-side execution time. */
1415
1889
  maxTimeMS(ms) {
1416
- this._options.maxTimeMS = ms;
1417
- return this;
1890
+ const next = this.fork();
1891
+ next._options.maxTimeMS = ms;
1892
+ return next;
1418
1893
  }
1419
1894
  /** Bypass the read cache for this query when `false`. */
1420
1895
  cache(on) {
1421
- this._options.cache = on;
1422
- return this;
1896
+ const next = this.fork();
1897
+ next._options.cache = on;
1898
+ return next;
1423
1899
  }
1424
1900
  /** Override in-flight dedup for this query when set. */
1425
1901
  dedupe(on) {
1426
- this._options.dedupe = on;
1427
- return this;
1902
+ const next = this.fork();
1903
+ next._options.dedupe = on;
1904
+ return next;
1428
1905
  }
1429
1906
  /** Compile the builder into driver options (does not execute). */
1430
1907
  compile() {
@@ -1466,7 +1943,7 @@ var QueryBuilder = class {
1466
1943
  options
1467
1944
  );
1468
1945
  }
1469
- /** Streaming cursor (raw driver cursor; no retry, applies batchSize). */
1946
+ /** Streaming cursor (raw driver cursor; no retry/cache/drift — caller-owned). */
1470
1947
  cursor() {
1471
1948
  return this.ctx.collection.find(
1472
1949
  this._filter,
@@ -1482,9 +1959,21 @@ var QueryBuilder = class {
1482
1959
  this.countOptions()
1483
1960
  );
1484
1961
  }
1485
- /** True when at least one document matches. */
1962
+ /** True when at least one document matches (`_id`-only projection — no doc transfer). */
1486
1963
  async exists() {
1487
- return await this.one() !== null;
1964
+ const options = this.compile();
1965
+ if (options.limit === void 0) options.limit = 1;
1966
+ options.projection = { _id: 1 };
1967
+ const doc = await this.ctx.run(
1968
+ "mongo.exists",
1969
+ this._filter,
1970
+ (opts) => this.ctx.collection.findOne(
1971
+ this._filter,
1972
+ opts
1973
+ ),
1974
+ options
1975
+ );
1976
+ return doc !== null;
1488
1977
  }
1489
1978
  };
1490
1979
 
@@ -1515,8 +2004,9 @@ var createRepository = (manager, collection, options = {}) => {
1515
2004
  findOne: (filter, o) => manager.getOne(collection, filter, o),
1516
2005
  findOneOrFail: (filter, o) => manager.getOneOrFail(collection, filter, o),
1517
2006
  findMany: (filter, o) => manager.findMany(collection, filter, {
1518
- ...o ?? {},
1519
- ...options.limit !== void 0 ? { limit: options.limit } : {}
2007
+ // Repository `limit` is a DEFAULT — a per-call option must win.
2008
+ ...options.limit !== void 0 ? { limit: options.limit } : {},
2009
+ ...o ?? {}
1520
2010
  }),
1521
2011
  findActive: (filter, o) => manager.findActive(collection, filter, o),
1522
2012
  count: (filter, o) => manager.countDocuments(collection, filter, o),
@@ -1544,9 +2034,25 @@ var createRepository = (manager, collection, options = {}) => {
1544
2034
 
1545
2035
  // src/schema/collections.ts
1546
2036
  var resolveItemName = (item) => "schema" in item ? item.schema.name : item.name;
2037
+ var assertTimestampsDeclared = (name, schema, timestamps) => {
2038
+ const ts = typeof timestamps === "boolean" ? {} : timestamps;
2039
+ const createdAt = ts.createdAt ?? "createdAt";
2040
+ const updatedAt = ts.updatedAt ?? "updatedAt";
2041
+ const properties = schema.properties ?? {};
2042
+ for (const field of [createdAt, updatedAt]) {
2043
+ if (!(field in properties)) {
2044
+ throw new Error(
2045
+ `defineCollections: "${name}" enables timestamps but its schema does not declare the field "${field}". Add \`${field}: s.date().optional()\` to the schema (or adjust \`timestamps\`) \u2014 otherwise strict validation rejects every stamped write.`
2046
+ );
2047
+ }
2048
+ }
2049
+ };
1547
2050
  var resolveItemValue = (item) => {
1548
2051
  if ("schema" in item) {
1549
2052
  const def = item;
2053
+ if (def.timestamps !== void 0) {
2054
+ assertTimestampsDeclared(def.name, def.schema, def.timestamps);
2055
+ }
1550
2056
  return {
1551
2057
  schema: def.schema,
1552
2058
  ...def.indexes ? { indexes: def.indexes } : {},
@@ -1558,6 +2064,9 @@ var resolveItemValue = (item) => {
1558
2064
  };
1559
2065
  var defineCollection = (name, schema, extras = {}) => {
1560
2066
  const namedSchema = { ...schema, name };
2067
+ if (extras.timestamps !== void 0) {
2068
+ assertTimestampsDeclared(name, schema, extras.timestamps);
2069
+ }
1561
2070
  return {
1562
2071
  name,
1563
2072
  schema: namedSchema,
@@ -1587,6 +2096,11 @@ var addNumericBounds = (out, field) => {
1587
2096
  if (field.exclusiveMaximum) out.exclusiveMaximum = true;
1588
2097
  return out;
1589
2098
  };
2099
+ var NUMERIC_WIRE_UNION = {
2100
+ double: ["double", "int", "long"],
2101
+ long: ["long", "int", "double"]
2102
+ };
2103
+ var INTEGER_WIRE_UNION = ["int", "long", "double"];
1590
2104
  var convertField = (field) => {
1591
2105
  if (field.kind === "raw") {
1592
2106
  const out2 = { ...field.fragment };
@@ -1603,13 +2117,15 @@ var convertField = (field) => {
1603
2117
  if (field.pattern) out.pattern = field.pattern;
1604
2118
  return out;
1605
2119
  case "number":
1606
- out.bsonType = field.integer ? "int" : "number";
2120
+ out.bsonType = field.integer ? [...INTEGER_WIRE_UNION] : "number";
2121
+ if (field.integer && field.multipleOf === void 0) out.multipleOf = 1;
1607
2122
  return addNumericBounds(out, field);
1608
2123
  case "double":
1609
- out.bsonType = "double";
2124
+ out.bsonType = [...NUMERIC_WIRE_UNION.double];
1610
2125
  return addNumericBounds(out, field);
1611
2126
  case "long":
1612
- out.bsonType = "long";
2127
+ out.bsonType = [...NUMERIC_WIRE_UNION.long];
2128
+ if (field.multipleOf === void 0) out.multipleOf = 1;
1613
2129
  return addNumericBounds(out, field);
1614
2130
  case "decimal":
1615
2131
  out.bsonType = "decimal";
@@ -1625,6 +2141,7 @@ var convertField = (field) => {
1625
2141
  return out;
1626
2142
  case "geoPoint":
1627
2143
  return {
2144
+ ...field.description ? { description: field.description } : {},
1628
2145
  bsonType: "object",
1629
2146
  required: ["type", "coordinates"],
1630
2147
  properties: {
@@ -1650,7 +2167,10 @@ var convertField = (field) => {
1650
2167
  else if (typeof v === "string") types.add("string");
1651
2168
  else if (typeof v === "number") {
1652
2169
  const allInt = field.values.every((x) => typeof x !== "number" || Number.isInteger(x));
1653
- types.add(allInt ? "int" : "number");
2170
+ const fitsInt32 = allInt && field.values.every(
2171
+ (x) => typeof x !== "number" || x >= -2147483648 && x <= 2147483647
2172
+ );
2173
+ types.add(allInt && fitsInt32 ? "int" : "number");
1654
2174
  break;
1655
2175
  }
1656
2176
  }
@@ -1804,6 +2324,10 @@ var numericValue = (value) => {
1804
2324
  const n = Number(value.toString());
1805
2325
  return Number.isNaN(n) ? void 0 : n;
1806
2326
  }
2327
+ if (value instanceof Long) {
2328
+ const n = value.toNumber();
2329
+ return Number.isNaN(n) ? void 0 : n;
2330
+ }
1807
2331
  if (typeof value === "number" && !Number.isNaN(value)) return value;
1808
2332
  return void 0;
1809
2333
  };
@@ -1861,7 +2385,7 @@ var bsonTypeMatches = (bsonType, value) => {
1861
2385
  case "double":
1862
2386
  return typeof value === "number" && !Number.isNaN(value);
1863
2387
  case "long":
1864
- return typeof value === "number" && Number.isInteger(value);
2388
+ return typeof value === "number" && Number.isInteger(value) || value instanceof Long;
1865
2389
  case "decimal":
1866
2390
  return value instanceof Decimal128;
1867
2391
  default:
@@ -1883,6 +2407,19 @@ var checkReservedValue = (key, value, path, issues) => {
1883
2407
  };
1884
2408
 
1885
2409
  // src/schema/validate-doc/field-validators.ts
2410
+ var patternCache = /* @__PURE__ */ new WeakMap();
2411
+ var compiledPattern = (field) => {
2412
+ if (!field.pattern) return void 0;
2413
+ const cached = patternCache.get(field);
2414
+ if (cached) return cached;
2415
+ try {
2416
+ const re = new RegExp(field.pattern);
2417
+ patternCache.set(field, re);
2418
+ return re;
2419
+ } catch {
2420
+ return void 0;
2421
+ }
2422
+ };
1886
2423
  var stringValidator = (_deps, field, value, path, issues) => {
1887
2424
  const f = field;
1888
2425
  if (typeof value !== "string") {
@@ -1907,19 +2444,15 @@ var stringValidator = (_deps, field, value, path, issues) => {
1907
2444
  `Length ${value.length} > ${f.maxLength}`
1908
2445
  );
1909
2446
  }
1910
- if (f.pattern) {
1911
- try {
1912
- if (!new RegExp(f.pattern).test(value)) {
1913
- push(
1914
- issues,
1915
- path,
1916
- "constraint",
1917
- `string(pattern ${f.pattern})`,
1918
- `"${value}" does not match pattern`
1919
- );
1920
- }
1921
- } catch {
1922
- }
2447
+ const pattern = compiledPattern(f);
2448
+ if (pattern && !pattern.test(value)) {
2449
+ push(
2450
+ issues,
2451
+ path,
2452
+ "constraint",
2453
+ `string(pattern ${f.pattern})`,
2454
+ `"${value}" does not match pattern`
2455
+ );
1923
2456
  }
1924
2457
  };
1925
2458
  var numberValidator = (_deps, field, value, path, issues) => {
@@ -1936,7 +2469,7 @@ var numberValidator = (_deps, field, value, path, issues) => {
1936
2469
  };
1937
2470
  var floatingValidator = (_deps, field, value, path, issues) => {
1938
2471
  const f = field;
1939
- const ok = f.kind === "decimal" ? value instanceof Decimal128 : f.kind === "long" ? typeof value === "number" && Number.isInteger(value) : typeof value === "number" && !Number.isNaN(value);
2472
+ const ok = f.kind === "decimal" ? value instanceof Decimal128 : f.kind === "long" ? typeof value === "number" && Number.isInteger(value) || value instanceof Long : typeof value === "number" && !Number.isNaN(value);
1940
2473
  if (!ok) {
1941
2474
  push(issues, path, "type", f.kind, `Expected ${f.kind}, got ${describeValue(value)}`);
1942
2475
  return;
@@ -2093,7 +2626,7 @@ function validateObjectProps(field, value, path, issues, topLevel) {
2093
2626
  validateValue(prop, value[key], propPath(key), issues);
2094
2627
  }
2095
2628
  if (field.additionalProperties !== true) {
2096
- const allowedReserved = topLevel ? /* @__PURE__ */ new Set(["_id", ...Object.keys(ORM_RESERVED_FIELDS)]) : /* @__PURE__ */ new Set();
2629
+ const allowedReserved = topLevel ? ALLOWED_RESERVED_TOP : NO_RESERVED;
2097
2630
  for (const key of Object.keys(value)) {
2098
2631
  if (key in field.properties) continue;
2099
2632
  if (allowedReserved.has(key)) continue;
@@ -2140,6 +2673,11 @@ function validateObjectProps(field, value, path, issues, topLevel) {
2140
2673
  }
2141
2674
  }
2142
2675
  var deps = { validateValue, validateObjectProps };
2676
+ var ALLOWED_RESERVED_TOP = /* @__PURE__ */ new Set([
2677
+ "_id",
2678
+ ...Object.keys(ORM_RESERVED_FIELDS)
2679
+ ]);
2680
+ var NO_RESERVED = /* @__PURE__ */ new Set();
2143
2681
  var validateDoc = (schema, doc) => {
2144
2682
  const issues = [];
2145
2683
  if (doc === null || typeof doc !== "object" || Array.isArray(doc)) {
@@ -2166,15 +2704,23 @@ var createResolveCollectionName = (config) => {
2166
2704
  // src/service/connection-uri.ts
2167
2705
  var normalizeMongoUrl = (rawUrl) => {
2168
2706
  try {
2169
- const url = new URL(rawUrl);
2170
- if (!url.searchParams.has("retryWrites")) url.searchParams.set("retryWrites", "false");
2171
- const hasWriteConcern = url.searchParams.has("w") || url.searchParams.has("wtimeoutMS") || url.searchParams.has("writeConcern");
2172
- if (!hasWriteConcern) url.searchParams.set("w", "1");
2173
- const hostCount = url.hostname.split(",").length;
2174
- if (hostCount === 1 && !url.searchParams.has("replicaSet") && !url.searchParams.has("directConnection")) {
2175
- url.searchParams.set("directConnection", "true");
2707
+ const qIndex = rawUrl.indexOf("?");
2708
+ const base = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
2709
+ const query = qIndex === -1 ? "" : rawUrl.slice(qIndex + 1);
2710
+ const params = new URLSearchParams(query);
2711
+ if (!params.has("retryWrites")) params.set("retryWrites", "false");
2712
+ const hasWriteConcern = params.has("w") || params.has("wtimeoutMS") || params.has("writeConcern");
2713
+ if (!hasWriteConcern) params.set("w", "1");
2714
+ const schemeSep = base.indexOf("://");
2715
+ const authority = schemeSep === -1 ? base : base.slice(schemeSep + 3).split("/")[0] ?? "";
2716
+ const hostPart = authority.includes("@") ? authority.split("@").pop() ?? "" : authority;
2717
+ const hostCount = hostPart === "" ? 0 : hostPart.split(",").length;
2718
+ const isSrv = base.startsWith("mongodb+srv://");
2719
+ if (!isSrv && hostCount === 1 && !params.has("replicaSet") && !params.has("directConnection")) {
2720
+ params.set("directConnection", "true");
2176
2721
  }
2177
- return url.toString();
2722
+ const queryString = params.toString();
2723
+ return queryString ? `${base}?${queryString}` : base;
2178
2724
  } catch {
2179
2725
  return rawUrl;
2180
2726
  }
@@ -2210,7 +2756,12 @@ var CacheInvalidator = class {
2210
2756
  const key = `${ref.db.databaseName}${SEP2}${ref.collection}`;
2211
2757
  if (seen.has(key) || this.streams.has(key)) continue;
2212
2758
  seen.add(key);
2213
- void this.watchLoop(key, ref);
2759
+ void this.watchLoop(key, ref).catch((err) => {
2760
+ this.logger.error?.(
2761
+ { error: err instanceof Error ? err.message : String(err), collection: ref.collection },
2762
+ "cacheWatch: watcher crashed"
2763
+ );
2764
+ });
2214
2765
  }
2215
2766
  }
2216
2767
  /** Close every watcher. Safe to call repeatedly, and before any `start`. */
@@ -2230,11 +2781,17 @@ var CacheInvalidator = class {
2230
2781
  async watchLoop(key, ref) {
2231
2782
  let backoff = 0;
2232
2783
  while (!this.stopped) {
2784
+ let connectedAt = Date.now();
2233
2785
  try {
2234
2786
  const stream = ref.db.collection(ref.collection).watch([]);
2235
- stream.on("error", () => {
2787
+ stream.on("error", (err) => {
2788
+ this.logger.debug?.(
2789
+ { error: err instanceof Error ? err.message : String(err) },
2790
+ "cacheWatch: stream error event"
2791
+ );
2236
2792
  });
2237
2793
  this.streams.set(key, stream);
2794
+ connectedAt = Date.now();
2238
2795
  if (backoff > 0) {
2239
2796
  this.cache.invalidateByCollection(
2240
2797
  cacheCollectionKey(ref.db.databaseName, ref.collection)
@@ -2247,6 +2804,7 @@ var CacheInvalidator = class {
2247
2804
  );
2248
2805
  }
2249
2806
  } catch (err) {
2807
+ if (Date.now() - connectedAt > 6e4) backoff = 0;
2250
2808
  const message = err instanceof Error ? err.message : String(err);
2251
2809
  const abandoned = this.streams.get(key);
2252
2810
  this.streams.delete(key);
@@ -2256,13 +2814,13 @@ var CacheInvalidator = class {
2256
2814
  } catch {
2257
2815
  }
2258
2816
  }
2259
- if (/only supported on replica sets/i.test(message)) {
2817
+ if (isPermanentWatchError(message)) {
2260
2818
  this.disabled = true;
2261
2819
  if (!this.warnedUnsupported) {
2262
2820
  this.warnedUnsupported = true;
2263
2821
  this.logger.warn?.(
2264
2822
  { collection: ref.collection, error: message },
2265
- "cacheWatch: change streams are not supported here (standalone?) \u2014 the query cache stays write-through only; set cache: { ttlMs } or disable cacheWatch"
2823
+ "cacheWatch: change streams unavailable permanently \u2014 the query cache stays write-through only; set cache: { ttlMs } or disable cacheWatch"
2266
2824
  );
2267
2825
  }
2268
2826
  return;
@@ -2273,7 +2831,7 @@ var CacheInvalidator = class {
2273
2831
  "cacheWatch: change stream error, retrying with backoff"
2274
2832
  );
2275
2833
  backoff = Math.min(backoff + 1e3, 5e3);
2276
- await sleepJittered(backoff);
2834
+ await sleepJittered(backoff, 1e3, true);
2277
2835
  }
2278
2836
  }
2279
2837
  }
@@ -2352,6 +2910,7 @@ var makeGetDbUrl = (dbClients, config) => (name) => {
2352
2910
  return normalizeMongoUrl(config.defaultDb);
2353
2911
  };
2354
2912
  var makeConnectionFactory = (dbClients, db, getDbUrl, buildManager2, openedClients = /* @__PURE__ */ new Map()) => {
2913
+ const connecting = /* @__PURE__ */ new Map();
2355
2914
  return async (names) => {
2356
2915
  const keys = names && names.length > 0 ? names : Object.keys(dbClients);
2357
2916
  const openedThisCall = [];
@@ -2362,18 +2921,32 @@ var makeConnectionFactory = (dbClients, db, getDbUrl, buildManager2, openedClien
2362
2921
  const dbUrl = getDbUrl(key);
2363
2922
  let client = openedClients.get(dbUrl);
2364
2923
  if (!client) {
2365
- client = new MongoClient(dbUrl, {
2366
- connectTimeoutMS: definition.connectTimeoutMs ?? 1e4,
2367
- serverSelectionTimeoutMS: definition.connectTimeoutMs ?? 1e4,
2368
- ...definition.readPreference && { readPreference: definition.readPreference },
2369
- ...definition.readConcern && { readConcern: definition.readConcern },
2370
- ...definition.writeConcern && { writeConcern: definition.writeConcern }
2371
- });
2372
- client.on("error", () => {
2373
- });
2374
- openedClients.set(dbUrl, client);
2375
- openedThisCall.push({ url: dbUrl, client });
2376
- await client.connect();
2924
+ const inflight = connecting.get(dbUrl);
2925
+ if (inflight) {
2926
+ client = await inflight;
2927
+ } else {
2928
+ const attempt = (async () => {
2929
+ const c = new MongoClient(dbUrl, {
2930
+ connectTimeoutMS: definition.connectTimeoutMs ?? 1e4,
2931
+ serverSelectionTimeoutMS: definition.connectTimeoutMs ?? 1e4,
2932
+ ...definition.readPreference && { readPreference: definition.readPreference },
2933
+ ...definition.readConcern && { readConcern: definition.readConcern },
2934
+ ...definition.writeConcern && { writeConcern: definition.writeConcern }
2935
+ });
2936
+ c.on("error", () => {
2937
+ });
2938
+ await c.connect();
2939
+ return c;
2940
+ })();
2941
+ connecting.set(dbUrl, attempt);
2942
+ try {
2943
+ client = await attempt;
2944
+ openedClients.set(dbUrl, client);
2945
+ openedThisCall.push({ url: dbUrl, client });
2946
+ } finally {
2947
+ connecting.delete(dbUrl);
2948
+ }
2949
+ }
2377
2950
  }
2378
2951
  const dbKey = `${key}Client`;
2379
2952
  db[dbKey] = buildManager2(key, definition.name, client);
@@ -2401,36 +2974,45 @@ var closeConnections = async (openedClients) => {
2401
2974
  };
2402
2975
 
2403
2976
  // src/service/health.ts
2404
- var createHealth = (db) => async () => {
2977
+ var createHealth = (db, options = {}) => async () => {
2405
2978
  const started = performance.now();
2979
+ const timeoutMs = options.timeoutMs ?? 2e3;
2406
2980
  const dbs = await Promise.all(
2407
2981
  Object.entries(db).map(async ([key, manager]) => {
2408
2982
  const name = key.replace(/Client$/, "");
2409
2983
  const handle = manager?.client;
2410
- if (!handle) return { name, ok: false };
2984
+ if (!handle) return { name, ok: false, error: "no client handle on manager" };
2411
2985
  try {
2412
2986
  const t0 = performance.now();
2413
- await withTimeout(handle.admin().command({ ping: 1 }), 2e3, `health:${name}`);
2987
+ await withTimeout(handle.admin().command({ ping: 1 }), timeoutMs, `health:${name}`);
2414
2988
  return { name, ok: true, latencyMs: performance.now() - t0 };
2415
- } catch {
2416
- return { name, ok: false };
2989
+ } catch (err) {
2990
+ return {
2991
+ name,
2992
+ ok: false,
2993
+ error: err instanceof Error ? err.message : String(err)
2994
+ };
2417
2995
  }
2418
2996
  })
2419
2997
  );
2420
- return { ok: dbs.every((r) => r.ok), latencyMs: performance.now() - started, dbs };
2998
+ return {
2999
+ ok: dbs.length > 0 && dbs.every((r) => r.ok),
3000
+ latencyMs: performance.now() - started,
3001
+ dbs
3002
+ };
2421
3003
  };
2422
3004
 
2423
3005
  // src/relation/populate.ts
2424
3006
  var createForeignKeyLoader = (deps2, collection, foreignField, options = {}) => new DataLoader({
2425
- maxBatchSize: options.maxBatchSize,
2426
- cache: options.cache,
3007
+ ...options.maxBatchSize !== void 0 ? { maxBatchSize: options.maxBatchSize } : {},
3008
+ ...options.cache !== void 0 ? { cache: options.cache } : {},
2427
3009
  keyOf: canonicalKey,
2428
3010
  batch: async (keys) => {
2429
3011
  const docs = await deps2.findMany(
2430
3012
  collection,
2431
3013
  { [foreignField]: { $in: keys } },
2432
3014
  {
2433
- limit: options.maxDocs
3015
+ ...options.maxDocs !== void 0 ? { limit: options.maxDocs } : {}
2434
3016
  }
2435
3017
  );
2436
3018
  const index = /* @__PURE__ */ new Map();
@@ -2463,57 +3045,72 @@ var distinctKeys = (values) => {
2463
3045
  };
2464
3046
  var makePopulator = (deps2) => {
2465
3047
  const populate = async (docs, relations, options = {}) => {
2466
- for (const relation of relations) {
2467
- const target = relation.foreignField ?? "_id";
2468
- const sourceKeys = distinctKeys(docs.map((doc) => doc[relation.localField]));
2469
- if (relation.type === "belongsTo") {
2470
- const loader = createForeignKeyLoader(deps2, relation.collection, target, options);
2471
- const values = await loader.loadMany(sourceKeys);
2472
- const byKey = new Map(sourceKeys.map((key, i) => [canonicalKey(key), values[i]]));
2473
- for (const doc of docs) {
2474
- const key = doc[relation.localField];
2475
- const match = key === void 0 || key === null ? void 0 : byKey.get(canonicalKey(key))?.[0];
2476
- doc[relation.as] = match ?? (relation.optional ? void 0 : null);
2477
- }
2478
- } else if (relation.type === "hasMany") {
2479
- const loader = createForeignKeyLoader(deps2, relation.collection, target, options);
2480
- const values = await loader.loadMany(sourceKeys);
2481
- const byKey = new Map(sourceKeys.map((key, i) => [canonicalKey(key), values[i] ?? []]));
2482
- for (const doc of docs) {
2483
- const key = doc[relation.localField];
2484
- doc[relation.as] = key === void 0 || key === null ? [] : byKey.get(canonicalKey(key)) ?? [];
2485
- }
2486
- } else {
2487
- const throughLoader = createForeignKeyLoader(
2488
- deps2,
2489
- relation.through.collection,
2490
- relation.through.localField,
2491
- options
2492
- );
2493
- const throughValues = await throughLoader.loadMany(sourceKeys);
2494
- const throughByKey = new Map(
2495
- sourceKeys.map((key, i) => [canonicalKey(key), throughValues[i] ?? []])
2496
- );
2497
- const joinDocs = throughValues.flat();
2498
- const targetKeys = distinctKeys(
2499
- joinDocs.map((join3) => join3[relation.through.foreignField])
2500
- );
2501
- const targetLoader = createForeignKeyLoader(deps2, relation.collection, target, options);
2502
- const targetValues = await targetLoader.loadMany(targetKeys);
2503
- const targetByKey = new Map(
2504
- targetKeys.map((key, i) => [canonicalKey(key), targetValues[i] ?? []])
2505
- );
2506
- for (const doc of docs) {
2507
- const key = doc[relation.localField];
2508
- const joins = key === void 0 || key === null ? [] : throughByKey.get(canonicalKey(key)) ?? [];
2509
- const targets = joins.flatMap(
2510
- (join3) => targetByKey.get(canonicalKey(join3[relation.through.foreignField])) ?? []
3048
+ const out = docs.map((doc) => ({ ...doc }));
3049
+ if (out.length === 0 || relations.length === 0) return out;
3050
+ const loaderCache = /* @__PURE__ */ new Map();
3051
+ const loaderFor = (collection, foreignField) => {
3052
+ const cacheKey = `${collection}\0${foreignField}`;
3053
+ let loader = loaderCache.get(cacheKey);
3054
+ if (!loader) {
3055
+ loader = createForeignKeyLoader(deps2, collection, foreignField, options);
3056
+ loaderCache.set(cacheKey, loader);
3057
+ }
3058
+ return loader;
3059
+ };
3060
+ const loadIndex = async (collection, foreignField, keys) => {
3061
+ const values = await loaderFor(collection, foreignField).loadMany(keys);
3062
+ const byKey = /* @__PURE__ */ new Map();
3063
+ keys.forEach((key, i) => {
3064
+ byKey.set(canonicalKey(key), values[i] ?? []);
3065
+ });
3066
+ return byKey;
3067
+ };
3068
+ await Promise.all(
3069
+ relations.map(async (relation) => {
3070
+ const target = relation.foreignField ?? "_id";
3071
+ const sourceKeys = distinctKeys(out.map((doc) => doc[relation.localField]));
3072
+ if (relation.type === "belongsTo") {
3073
+ const byKey = await loadIndex(relation.collection, target, sourceKeys);
3074
+ for (const doc of out) {
3075
+ const key = doc[relation.localField];
3076
+ const matches = key === void 0 || key === null ? void 0 : byKey.get(canonicalKey(key));
3077
+ doc[relation.as] = matches?.[0] ?? (relation.optional ? void 0 : null);
3078
+ }
3079
+ } else if (relation.type === "hasMany") {
3080
+ const byKey = await loadIndex(relation.collection, target, sourceKeys);
3081
+ for (const doc of out) {
3082
+ const key = doc[relation.localField];
3083
+ doc[relation.as] = key === void 0 || key === null ? [] : byKey.get(canonicalKey(key)) ?? [];
3084
+ }
3085
+ } else {
3086
+ const throughByKey = await loadIndex(
3087
+ relation.through.collection,
3088
+ relation.through.localField,
3089
+ sourceKeys
3090
+ );
3091
+ const joinDocs = [...throughByKey.values()].flat();
3092
+ const targetKeys = distinctKeys(
3093
+ joinDocs.map((join3) => join3[relation.through.foreignField])
2511
3094
  );
2512
- doc[relation.as] = targets;
3095
+ const targetByKey = await loadIndex(relation.collection, target, targetKeys);
3096
+ for (const doc of out) {
3097
+ const key = doc[relation.localField];
3098
+ const joins = key === void 0 || key === null ? [] : throughByKey.get(canonicalKey(key)) ?? [];
3099
+ const seenTargets = /* @__PURE__ */ new Set();
3100
+ const targets = [];
3101
+ for (const join3 of joins) {
3102
+ const tk = canonicalKey(join3[relation.through.foreignField]);
3103
+ if (seenTargets.has(tk)) continue;
3104
+ seenTargets.add(tk);
3105
+ const bucket = targetByKey.get(tk);
3106
+ if (bucket) targets.push(...bucket);
3107
+ }
3108
+ doc[relation.as] = targets;
3109
+ }
2513
3110
  }
2514
- }
2515
- }
2516
- return docs;
3111
+ })
3112
+ );
3113
+ return out;
2517
3114
  };
2518
3115
  return { populate };
2519
3116
  };
@@ -2535,11 +3132,23 @@ var asCollectionDefinition = (value) => {
2535
3132
  };
2536
3133
 
2537
3134
  // src/service/trace-db-op.ts
3135
+ var debugQueryCache;
3136
+ var probeDebugQuery = async () => {
3137
+ if (debugQueryCache !== void 0) return;
3138
+ try {
3139
+ const mod = await import('@ignex/core/debug');
3140
+ debugQueryCache = typeof mod.debugQuery === "function" ? mod.debugQuery : null;
3141
+ } catch {
3142
+ debugQueryCache = null;
3143
+ }
3144
+ };
2538
3145
  var traceDbOp = async (logger, meta, fn, options = {}) => {
2539
3146
  const start = performance.now();
2540
3147
  logger.debug({ ...meta, operation: "start" }, "start");
2541
3148
  try {
2542
- const result = await fn();
3149
+ if (debugQueryCache === void 0) await probeDebugQuery();
3150
+ const debugQuery = debugQueryCache;
3151
+ const result = debugQuery ? await debugQuery(`${meta.collection}.${meta.op}`, void 0, () => fn()) : await fn();
2543
3152
  const durationMs = performance.now() - start;
2544
3153
  logger.info({ ...meta, durationMs }, "ok");
2545
3154
  return result;
@@ -2584,10 +3193,16 @@ var resolveQueryOptions = (options) => {
2584
3193
  return {
2585
3194
  maxAttempts,
2586
3195
  retryDelayMs,
2587
- dedupe,
2588
- cache,
2589
- retryWrites,
2590
- sdk: { session, maxTimeMS, hint, batchSize, drift },
3196
+ ...dedupe !== void 0 ? { dedupe } : {},
3197
+ ...cache !== void 0 ? { cache } : {},
3198
+ ...retryWrites !== void 0 ? { retryWrites } : {},
3199
+ sdk: {
3200
+ ...session !== void 0 ? { session } : {},
3201
+ ...maxTimeMS !== void 0 ? { maxTimeMS } : {},
3202
+ ...hint !== void 0 ? { hint } : {},
3203
+ ...batchSize !== void 0 ? { batchSize } : {},
3204
+ ...drift !== void 0 ? { drift } : {}
3205
+ },
2591
3206
  driverOpts: rest
2592
3207
  };
2593
3208
  };
@@ -2699,7 +3314,7 @@ var PipelineBuilder = class _PipelineBuilder {
2699
3314
  collection: this.deps.logical,
2700
3315
  opName: "mongo.pipeline",
2701
3316
  pipeline: this.stages,
2702
- options: this.deps.options,
3317
+ ...this.deps.options !== void 0 ? { options: this.deps.options } : {},
2703
3318
  execute: run
2704
3319
  });
2705
3320
  }
@@ -2726,7 +3341,7 @@ var PipelineBuilder = class _PipelineBuilder {
2726
3341
  collection: this.deps.logical,
2727
3342
  opName: "mongo.pipeline.first",
2728
3343
  pipeline: this.stages,
2729
- options: this.deps.options,
3344
+ ...this.deps.options !== void 0 ? { options: this.deps.options } : {},
2730
3345
  execute: run
2731
3346
  });
2732
3347
  }
@@ -2863,7 +3478,7 @@ var makeAggregateOps = (ctx) => {
2863
3478
  coll: () => coll(collection),
2864
3479
  crudDeps: deps2,
2865
3480
  mergeDriver: mergeAggOptions2,
2866
- options,
3481
+ ...options !== void 0 ? { options } : {},
2867
3482
  cachedAggregate
2868
3483
  });
2869
3484
  return { aggregate, pipeline };
@@ -2872,10 +3487,10 @@ var makeAggregateOps = (ctx) => {
2872
3487
  // src/service/aggregation/helpers.ts
2873
3488
  var mergeAggOptions = (driverOpts, sdk) => ({
2874
3489
  ...driverOpts,
2875
- session: sdk.session,
2876
- maxTimeMS: sdk.maxTimeMS,
2877
- hint: sdk.hint,
2878
- batchSize: sdk.batchSize
3490
+ ...sdk.session !== void 0 ? { session: sdk.session } : {},
3491
+ ...sdk.maxTimeMS !== void 0 ? { maxTimeMS: sdk.maxTimeMS } : {},
3492
+ ...sdk.hint !== void 0 ? { hint: sdk.hint } : {},
3493
+ ...sdk.batchSize !== void 0 ? { batchSize: sdk.batchSize } : {}
2879
3494
  });
2880
3495
  var DATE_PART_FORMATS = {
2881
3496
  hour: "%Y-%m-%dT%H",
@@ -2971,10 +3586,12 @@ var createCachedAggregate = (ctx) => {
2971
3586
  return runDriver();
2972
3587
  };
2973
3588
  if (shouldDedupe) {
2974
- const dedupeKey = `${cacheCollectionKey(client.databaseName, physical)}|${opName}|${stableHash([pipeline, options])}`;
2975
- return opts.inFlight.run(dedupeKey, runOnce);
3589
+ const primaryKey = cacheCollectionKey(client.databaseName, physical);
3590
+ const gen = cache !== void 0 ? cache.versionOf(primaryKey) : "";
3591
+ const dedupeKey = `${primaryKey}|${gen}|${opName}|${stableHash([pipeline, options])}`;
3592
+ return await opts.inFlight.run(dedupeKey, runOnce);
2976
3593
  }
2977
- return runOnce();
3594
+ return await runOnce();
2978
3595
  };
2979
3596
  };
2980
3597
 
@@ -2997,7 +3614,7 @@ var makeGroupOps = (ctx) => {
2997
3614
  collection,
2998
3615
  opName: "mongo.groupBy",
2999
3616
  pipeline,
3000
- options,
3617
+ ...options !== void 0 ? { options } : {},
3001
3618
  execute: (r) => coll(collection).aggregate(pipeline, mergeAggOptions2(r.driverOpts, r.sdk)).toArray()
3002
3619
  });
3003
3620
  };
@@ -3027,7 +3644,7 @@ var makeGroupOps = (ctx) => {
3027
3644
  collection,
3028
3645
  opName: "mongo.dateRangeAnalysis",
3029
3646
  pipeline,
3030
- options,
3647
+ ...options !== void 0 ? { options } : {},
3031
3648
  execute: (r) => coll(collection).aggregate(pipeline, mergeAggOptions2(r.driverOpts, r.sdk)).toArray()
3032
3649
  });
3033
3650
  };
@@ -3059,7 +3676,7 @@ var makeLookupJoinOp = (ctx) => {
3059
3676
  collection,
3060
3677
  opName: "mongo.lookupJoin",
3061
3678
  pipeline,
3062
- options,
3679
+ ...options !== void 0 ? { options } : {},
3063
3680
  // Writes to any joined collection must invalidate the cached join.
3064
3681
  sources: lookups.map((l) => l.fromCollection),
3065
3682
  execute: (r) => coll(collection).aggregate(pipeline, mergeAggOptions2(r.driverOpts, r.sdk)).toArray()
@@ -3133,7 +3750,10 @@ var makeTextSearchOp = (ctx) => {
3133
3750
  const { coll, mergeAggOptions: mergeAggOptions2, cachedAggregate } = ctx;
3134
3751
  const textSearch = async (collection, baseFilter, searchConfig, paginationConfig = {}, customization = {}, options) => {
3135
3752
  const normalized = normalizePageLimit(
3136
- { page: paginationConfig.page, limit: paginationConfig.limit },
3753
+ {
3754
+ ...paginationConfig.page !== void 0 ? { page: paginationConfig.page } : {},
3755
+ ...paginationConfig.limit !== void 0 ? { limit: paginationConfig.limit } : {}
3756
+ },
3137
3757
  paginationConfig.maxLimit ?? DEFAULT_MAX_LIMIT
3138
3758
  );
3139
3759
  const searchStages = buildSearchStages(
@@ -3160,7 +3780,7 @@ var makeTextSearchOp = (ctx) => {
3160
3780
  collection,
3161
3781
  opName: "mongo.textSearch",
3162
3782
  pipeline,
3163
- options,
3783
+ ...options !== void 0 ? { options } : {},
3164
3784
  execute: async (r) => {
3165
3785
  const rows = await coll(collection).aggregate(pipeline, mergeAggOptions2(r.driverOpts, r.sdk)).toArray();
3166
3786
  const row = rows[0];
@@ -3193,6 +3813,54 @@ var makeAggregationOps = (client, dbLabel, logger, opts) => {
3193
3813
  };
3194
3814
  };
3195
3815
 
3816
+ // src/schema/apply-defaults.ts
3817
+ var cloneDefault = (value) => {
3818
+ if (value === null || typeof value !== "object") return value;
3819
+ if (typeof structuredClone === "function") return structuredClone(value);
3820
+ return Array.isArray(value) ? [...value] : { ...value };
3821
+ };
3822
+ var applySchemaDefaults = (schema, doc) => {
3823
+ if (!schema || doc === null || typeof doc !== "object" || Array.isArray(doc)) return;
3824
+ for (const [key, prop] of Object.entries(schema.properties)) {
3825
+ if (key in doc && doc[key] !== void 0) {
3826
+ if (prop.kind === "object" && doc[key] !== null && typeof doc[key] === "object" && !Array.isArray(doc[key])) {
3827
+ applySchemaDefaults(prop, doc[key]);
3828
+ }
3829
+ continue;
3830
+ }
3831
+ if (prop.flags.hasDefault) {
3832
+ doc[key] = cloneDefault(prop.flags.defaultValue);
3833
+ }
3834
+ }
3835
+ };
3836
+
3837
+ // src/utils/omit-undefined.ts
3838
+ var stripUndefinedKeys = (doc) => {
3839
+ for (const key of Object.keys(doc)) {
3840
+ if (doc[key] === void 0) {
3841
+ delete doc[key];
3842
+ }
3843
+ }
3844
+ return doc;
3845
+ };
3846
+ var stripUndefinedFromUpdate = (update) => {
3847
+ if (update === null || typeof update !== "object") return update;
3848
+ const doc = update;
3849
+ for (const operator of ["$set", "$setOnInsert"]) {
3850
+ const op = doc[operator];
3851
+ if (op !== null && typeof op === "object" && !Array.isArray(op)) {
3852
+ const opDoc = op;
3853
+ for (const key of Object.keys(opDoc)) {
3854
+ if (opDoc[key] === void 0) delete opDoc[key];
3855
+ }
3856
+ }
3857
+ }
3858
+ for (const key of Object.keys(doc)) {
3859
+ if (doc[key] === void 0) delete doc[key];
3860
+ }
3861
+ return update;
3862
+ };
3863
+
3196
3864
  // src/service/drift.ts
3197
3865
  var checkDocsDrift = (options, collection, op, docs) => {
3198
3866
  if (options.drift === "off") return;
@@ -3246,6 +3914,8 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3246
3914
  };
3247
3915
  const timestampsFor = (collection) => opts.timestamps?.[String(collection)];
3248
3916
  const stampCreate = (collection, doc) => {
3917
+ applySchemaDefaults(opts.getSchema?.(String(collection)), doc);
3918
+ stripUndefinedKeys(doc);
3249
3919
  const ts = timestampsFor(collection);
3250
3920
  if (!ts) return;
3251
3921
  const now = /* @__PURE__ */ new Date();
@@ -3253,6 +3923,7 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3253
3923
  if (ts.updatedAt && doc[ts.updatedAt] === void 0) doc[ts.updatedAt] = now;
3254
3924
  };
3255
3925
  const stampUpdate = (collection, update) => {
3926
+ stripUndefinedFromUpdate(update);
3256
3927
  const formatted = formatUpdateFilter(update);
3257
3928
  const ts = timestampsFor(collection);
3258
3929
  if (ts?.updatedAt) {
@@ -3262,11 +3933,13 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3262
3933
  return formatted;
3263
3934
  };
3264
3935
  const stampReplace = (collection, doc) => {
3936
+ applySchemaDefaults(opts.getSchema?.(String(collection)), doc);
3937
+ stripUndefinedKeys(doc);
3265
3938
  const ts = timestampsFor(collection);
3266
3939
  if (!ts?.updatedAt) return doc;
3267
3940
  return { ...doc, [ts.updatedAt]: /* @__PURE__ */ new Date() };
3268
3941
  };
3269
- const read = async (collection, opName, filter, options, execute, shape = "none") => {
3942
+ const read = async (collection, opName, filter, options, execute, shape = "none", keyExtra) => {
3270
3943
  const physical = resolve(String(collection));
3271
3944
  const resolved = resolveQueryOptions(options);
3272
3945
  const noSession = !resolved.sdk.session;
@@ -3278,7 +3951,12 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3278
3951
  if (resolved.driverOpts.projection !== void 0) return;
3279
3952
  const docs = shape === "many" ? value : shape === "one" && value !== null ? [value] : [];
3280
3953
  checkDocsDrift(
3281
- { logger, db: dbLabel, drift: mode, getSchema: opts.getSchema },
3954
+ {
3955
+ logger,
3956
+ db: dbLabel,
3957
+ drift: mode,
3958
+ ...opts.getSchema !== void 0 ? { getSchema: opts.getSchema } : {}
3959
+ },
3282
3960
  String(collection),
3283
3961
  opName,
3284
3962
  docs
@@ -3291,10 +3969,22 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3291
3969
  delayMs: resolved.retryDelayMs
3292
3970
  })
3293
3971
  );
3294
- const key = useCache ? opts.cache.key(
3295
- cacheCollectionKey(client.databaseName, physical),
3296
- stableHash([filter, options])
3297
- ) : void 0;
3972
+ const {
3973
+ maxAttempts: _ma,
3974
+ retryDelayMs: _rd,
3975
+ dedupe: _dd,
3976
+ cache: _ca,
3977
+ retryWrites: _rw,
3978
+ ...resultOptions
3979
+ } = options ?? {};
3980
+ let payloadHash;
3981
+ const getPayloadHash = () => {
3982
+ if (payloadHash === void 0) {
3983
+ payloadHash = stableHash([opName, keyExtra ?? "", filter, resultOptions]);
3984
+ }
3985
+ return payloadHash;
3986
+ };
3987
+ const key = useCache ? opts.cache.key(cacheCollectionKey(client.databaseName, physical), getPayloadHash()) : void 0;
3298
3988
  const fetchAndCheck = () => executeWrapped().then((value) => {
3299
3989
  checkDrift(value);
3300
3990
  return value;
@@ -3313,10 +4003,11 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3313
4003
  return fetchAndCheck();
3314
4004
  };
3315
4005
  if (shouldDedupe) {
3316
- const dedupeKey = `${cacheCollectionKey(client.databaseName, physical)}|${opName}|${stableHash([filter, options])}`;
3317
- return opts.inFlight.run(dedupeKey, runOnce);
4006
+ const colKey = cacheCollectionKey(client.databaseName, physical);
4007
+ const gen = opts.cache !== void 0 ? opts.cache.versionOf(colKey) : "";
4008
+ return await opts.inFlight.run(`${colKey}|${gen}|${getPayloadHash()}`, runOnce);
3318
4009
  }
3319
- return runOnce();
4010
+ return await runOnce();
3320
4011
  };
3321
4012
  const normalizeFindOptions = (options) => {
3322
4013
  if (!options) return {};
@@ -3358,7 +4049,7 @@ var mergeMongoActiveFilter = (softDelete, filter) => {
3358
4049
 
3359
4050
  // src/service/crud/read-ops.ts
3360
4051
  var makeReadOps = (ctx) => {
3361
- const { client, opts } = ctx;
4052
+ const { opts } = ctx;
3362
4053
  const { coll, toDriverFilter, read, normalizeFindOptions } = ctx;
3363
4054
  const getOne = async (collection, filter, options) => {
3364
4055
  const f = filter ?? {};
@@ -3371,11 +4062,14 @@ var makeReadOps = (ctx) => {
3371
4062
  async (r) => {
3372
4063
  const opts2 = {
3373
4064
  ...r.driverOpts,
3374
- session: r.sdk.session,
3375
- maxTimeMS: r.sdk.maxTimeMS,
3376
- hint: r.sdk.hint
4065
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {},
4066
+ ...r.sdk.maxTimeMS !== void 0 ? { maxTimeMS: r.sdk.maxTimeMS } : {},
4067
+ ...r.sdk.hint !== void 0 ? { hint: r.sdk.hint } : {}
3377
4068
  };
3378
- return coll(collection).findOne(toDriverFilter(f), opts2);
4069
+ return await coll(collection).findOne(
4070
+ toDriverFilter(f),
4071
+ opts2
4072
+ );
3379
4073
  },
3380
4074
  "one"
3381
4075
  );
@@ -3416,9 +4110,9 @@ var makeReadOps = (ctx) => {
3416
4110
  async (r) => {
3417
4111
  const opts2 = {
3418
4112
  ...r.driverOpts,
3419
- session: r.sdk.session,
3420
- maxTimeMS: r.sdk.maxTimeMS,
3421
- hint: r.sdk.hint
4113
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {},
4114
+ ...r.sdk.maxTimeMS !== void 0 ? { maxTimeMS: r.sdk.maxTimeMS } : {},
4115
+ ...r.sdk.hint !== void 0 ? { hint: r.sdk.hint } : {}
3422
4116
  };
3423
4117
  return coll(collection).find(toDriverFilter(filter ?? {}), opts2).toArray();
3424
4118
  },
@@ -3465,11 +4159,11 @@ var makeReadOps = (ctx) => {
3465
4159
  async (r) => {
3466
4160
  const opts2 = {
3467
4161
  ...r.driverOpts,
3468
- session: r.sdk.session,
3469
- maxTimeMS: r.sdk.maxTimeMS,
3470
- hint: r.sdk.hint
4162
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {},
4163
+ ...r.sdk.maxTimeMS !== void 0 ? { maxTimeMS: r.sdk.maxTimeMS } : {},
4164
+ ...r.sdk.hint !== void 0 ? { hint: r.sdk.hint } : {}
3471
4165
  };
3472
- return coll(collection).countDocuments(toDriverFilter(filter ?? {}), opts2);
4166
+ return await coll(collection).countDocuments(toDriverFilter(filter ?? {}), opts2);
3473
4167
  },
3474
4168
  "none"
3475
4169
  );
@@ -3484,9 +4178,9 @@ var makeReadOps = (ctx) => {
3484
4178
  async (r) => {
3485
4179
  const opts2 = {
3486
4180
  ...r.driverOpts,
3487
- session: r.sdk.session,
3488
- maxTimeMS: r.sdk.maxTimeMS,
3489
- hint: r.sdk.hint
4181
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {},
4182
+ ...r.sdk.maxTimeMS !== void 0 ? { maxTimeMS: r.sdk.maxTimeMS } : {},
4183
+ ...r.sdk.hint !== void 0 ? { hint: r.sdk.hint } : {}
3490
4184
  };
3491
4185
  return coll(collection).distinct(
3492
4186
  String(field),
@@ -3494,21 +4188,24 @@ var makeReadOps = (ctx) => {
3494
4188
  opts2
3495
4189
  );
3496
4190
  },
3497
- "none"
4191
+ "none",
4192
+ String(field)
3498
4193
  );
3499
4194
  };
3500
- const estimatedDocumentCount = async (collection, options) => ctx.run(
4195
+ const estimatedDocumentCount = async (collection, options) => read(
3501
4196
  collection,
3502
4197
  "mongo.estimatedDocumentCount",
4198
+ {},
4199
+ options,
3503
4200
  async (r) => {
3504
4201
  const opts2 = {
3505
4202
  ...r.driverOpts,
3506
- session: r.sdk.session,
3507
- maxTimeMS: r.sdk.maxTimeMS
4203
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {},
4204
+ ...r.sdk.maxTimeMS !== void 0 ? { maxTimeMS: r.sdk.maxTimeMS } : {}
3508
4205
  };
3509
- return coll(collection).estimatedDocumentCount(opts2);
4206
+ return await coll(collection).estimatedDocumentCount(opts2);
3510
4207
  },
3511
- options
4208
+ "none"
3512
4209
  );
3513
4210
  return {
3514
4211
  getOne,
@@ -3541,11 +4238,11 @@ var makeWatchOps = (ctx) => {
3541
4238
  async (r) => {
3542
4239
  const opts = {
3543
4240
  ...r.driverOpts,
3544
- session: r.sdk.session,
3545
- maxTimeMS: r.sdk.maxTimeMS,
3546
- hint: r.sdk.hint
4241
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {},
4242
+ ...r.sdk.maxTimeMS !== void 0 ? { maxTimeMS: r.sdk.maxTimeMS } : {},
4243
+ ...r.sdk.hint !== void 0 ? { hint: r.sdk.hint } : {}
3547
4244
  };
3548
- return execute(opts);
4245
+ return await execute(opts);
3549
4246
  },
3550
4247
  opName === "mongo.getOne" ? "one" : opName === "mongo.findMany" ? "many" : "none"
3551
4248
  )
@@ -3562,6 +4259,20 @@ var makeWriteOps = (ctx, readOps) => {
3562
4259
  const { opts } = ctx;
3563
4260
  const { coll, toDriverFilter, writeRun, invalidate, stampCreate, stampUpdate, stampReplace } = ctx;
3564
4261
  const { getOne } = readOps;
4262
+ const safeAfterHooks = async (collection, name, hookCtx) => {
4263
+ try {
4264
+ await runHooks(opts.hooks, collection, name, hookCtx);
4265
+ } catch (err) {
4266
+ ctx.logger.error?.(
4267
+ {
4268
+ collection,
4269
+ hook: name,
4270
+ error: err instanceof Error ? err.message : String(err)
4271
+ },
4272
+ `post-commit ${name} hook failed; DB write is already committed`
4273
+ );
4274
+ }
4275
+ };
3565
4276
  const insertOne = async (collection, doc, options) => {
3566
4277
  await runHooks(opts.hooks, String(collection), "beforeCreate", {
3567
4278
  collection: String(collection),
@@ -3574,37 +4285,40 @@ var makeWriteOps = (ctx, readOps) => {
3574
4285
  async (r) => {
3575
4286
  const insertOpts = {
3576
4287
  ...r.driverOpts,
3577
- session: r.sdk.session
4288
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {}
3578
4289
  };
3579
- return coll(collection).insertOne(doc, insertOpts);
4290
+ return await coll(collection).insertOne(
4291
+ doc,
4292
+ insertOpts
4293
+ );
3580
4294
  },
3581
4295
  options
3582
4296
  );
3583
4297
  invalidate(collection);
3584
- await runHooks(opts.hooks, String(collection), "afterCreate", {
4298
+ await safeAfterHooks(String(collection), "afterCreate", {
3585
4299
  collection: String(collection),
3586
4300
  doc
3587
4301
  });
3588
4302
  return result;
3589
4303
  };
3590
4304
  const insertMany = async (collection, docs, options) => {
3591
- await runHooks(opts.hooks, String(collection), "beforeCreate", {
3592
- collection: String(collection),
3593
- docs
3594
- });
3595
- for (const d of docs) stampCreate(collection, d);
3596
4305
  if (docs.length > MAX_BATCH_OPS) {
3597
4306
  throw new BadRequest(
3598
4307
  `insertMany: too many docs (${docs.length} > ${MAX_BATCH_OPS}); split the batch`
3599
4308
  );
3600
4309
  }
4310
+ await runHooks(opts.hooks, String(collection), "beforeCreate", {
4311
+ collection: String(collection),
4312
+ docs
4313
+ });
4314
+ for (const d of docs) stampCreate(collection, d);
3601
4315
  const result = await writeRun(
3602
4316
  collection,
3603
4317
  "mongo.insertMany",
3604
4318
  async (r) => {
3605
4319
  const bulkOpts = {
3606
4320
  ...r.driverOpts,
3607
- session: r.sdk.session
4321
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {}
3608
4322
  };
3609
4323
  return coll(collection).insertMany(
3610
4324
  docs,
@@ -3614,7 +4328,7 @@ var makeWriteOps = (ctx, readOps) => {
3614
4328
  options
3615
4329
  );
3616
4330
  invalidate(collection);
3617
- await runHooks(opts.hooks, String(collection), "afterCreate", {
4331
+ await safeAfterHooks(String(collection), "afterCreate", {
3618
4332
  collection: String(collection),
3619
4333
  docs
3620
4334
  });
@@ -3632,7 +4346,7 @@ var makeWriteOps = (ctx, readOps) => {
3632
4346
  async (r) => {
3633
4347
  const updateOpts = {
3634
4348
  ...r.driverOpts,
3635
- session: r.sdk.session
4349
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {}
3636
4350
  };
3637
4351
  return coll(collection).updateOne(
3638
4352
  toDriverFilter(filter),
@@ -3643,13 +4357,17 @@ var makeWriteOps = (ctx, readOps) => {
3643
4357
  options
3644
4358
  );
3645
4359
  invalidate(collection);
3646
- await runHooks(opts.hooks, String(collection), "afterUpdate", {
4360
+ await safeAfterHooks(String(collection), "afterUpdate", {
3647
4361
  collection: String(collection),
3648
4362
  filter
3649
4363
  });
3650
4364
  return result;
3651
4365
  };
3652
4366
  const updateMany = async (collection, filter, update, options) => {
4367
+ await runHooks(opts.hooks, String(collection), "beforeUpdate", {
4368
+ collection: String(collection),
4369
+ filter
4370
+ });
3653
4371
  const effective = stampUpdate(collection, update);
3654
4372
  const result = await writeRun(
3655
4373
  collection,
@@ -3657,7 +4375,7 @@ var makeWriteOps = (ctx, readOps) => {
3657
4375
  async (r) => {
3658
4376
  const updateOpts = {
3659
4377
  ...r.driverOpts,
3660
- session: r.sdk.session
4378
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {}
3661
4379
  };
3662
4380
  return coll(collection).updateMany(
3663
4381
  toDriverFilter(filter),
@@ -3668,9 +4386,17 @@ var makeWriteOps = (ctx, readOps) => {
3668
4386
  options
3669
4387
  );
3670
4388
  invalidate(collection);
4389
+ await safeAfterHooks(String(collection), "afterUpdate", {
4390
+ collection: String(collection),
4391
+ filter
4392
+ });
3671
4393
  return result;
3672
4394
  };
3673
4395
  const findOneAndUpdate = async (collection, filter, update, options) => {
4396
+ await runHooks(opts.hooks, String(collection), "beforeUpdate", {
4397
+ collection: String(collection),
4398
+ filter
4399
+ });
3674
4400
  const effective = stampUpdate(collection, update);
3675
4401
  const result = await writeRun(
3676
4402
  collection,
@@ -3678,9 +4404,9 @@ var makeWriteOps = (ctx, readOps) => {
3678
4404
  async (r) => {
3679
4405
  const fOpts = {
3680
4406
  ...r.driverOpts,
3681
- session: r.sdk.session,
3682
- maxTimeMS: r.sdk.maxTimeMS,
3683
- hint: r.sdk.hint,
4407
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {},
4408
+ ...r.sdk.maxTimeMS !== void 0 ? { maxTimeMS: r.sdk.maxTimeMS } : {},
4409
+ ...r.sdk.hint !== void 0 ? { hint: r.sdk.hint } : {},
3684
4410
  // `after` is the ORM's default, but honor an explicit caller override.
3685
4411
  returnDocument: r.driverOpts.returnDocument ?? "after"
3686
4412
  };
@@ -3693,9 +4419,17 @@ var makeWriteOps = (ctx, readOps) => {
3693
4419
  options
3694
4420
  );
3695
4421
  invalidate(collection);
4422
+ await safeAfterHooks(String(collection), "afterUpdate", {
4423
+ collection: String(collection),
4424
+ filter
4425
+ });
3696
4426
  return result;
3697
4427
  };
3698
4428
  const findOneAndReplace = async (collection, filter, replacement, options) => {
4429
+ await runHooks(opts.hooks, String(collection), "beforeUpdate", {
4430
+ collection: String(collection),
4431
+ filter
4432
+ });
3699
4433
  const effective = stampReplace(collection, replacement);
3700
4434
  const result = await writeRun(
3701
4435
  collection,
@@ -3703,9 +4437,9 @@ var makeWriteOps = (ctx, readOps) => {
3703
4437
  async (r) => {
3704
4438
  const fOpts = {
3705
4439
  ...r.driverOpts,
3706
- session: r.sdk.session,
3707
- maxTimeMS: r.sdk.maxTimeMS,
3708
- hint: r.sdk.hint,
4440
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {},
4441
+ ...r.sdk.maxTimeMS !== void 0 ? { maxTimeMS: r.sdk.maxTimeMS } : {},
4442
+ ...r.sdk.hint !== void 0 ? { hint: r.sdk.hint } : {},
3709
4443
  // `after` is the ORM's default, but honor an explicit caller override.
3710
4444
  returnDocument: r.driverOpts.returnDocument ?? "after"
3711
4445
  };
@@ -3718,16 +4452,24 @@ var makeWriteOps = (ctx, readOps) => {
3718
4452
  options
3719
4453
  );
3720
4454
  invalidate(collection);
4455
+ await safeAfterHooks(String(collection), "afterUpdate", {
4456
+ collection: String(collection),
4457
+ filter
4458
+ });
3721
4459
  return result;
3722
4460
  };
3723
4461
  const replaceOne = async (collection, filter, replacement, options) => {
4462
+ await runHooks(opts.hooks, String(collection), "beforeUpdate", {
4463
+ collection: String(collection),
4464
+ filter
4465
+ });
3724
4466
  const result = await writeRun(
3725
4467
  collection,
3726
4468
  "mongo.replaceOne",
3727
4469
  async (r) => {
3728
4470
  const repOpts = {
3729
4471
  ...r.driverOpts,
3730
- session: r.sdk.session
4472
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {}
3731
4473
  };
3732
4474
  return coll(collection).replaceOne(
3733
4475
  toDriverFilter(filter),
@@ -3738,6 +4480,10 @@ var makeWriteOps = (ctx, readOps) => {
3738
4480
  options
3739
4481
  );
3740
4482
  invalidate(collection);
4483
+ await safeAfterHooks(String(collection), "afterUpdate", {
4484
+ collection: String(collection),
4485
+ filter
4486
+ });
3741
4487
  return result;
3742
4488
  };
3743
4489
  const deleteOne = async (collection, filter, options) => {
@@ -3751,45 +4497,57 @@ var makeWriteOps = (ctx, readOps) => {
3751
4497
  async (r) => {
3752
4498
  const delOpts = {
3753
4499
  ...r.driverOpts,
3754
- session: r.sdk.session
4500
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {}
3755
4501
  };
3756
- return coll(collection).deleteOne(toDriverFilter(filter), delOpts);
4502
+ return await coll(collection).deleteOne(toDriverFilter(filter), delOpts);
3757
4503
  },
3758
4504
  options
3759
4505
  );
3760
4506
  invalidate(collection);
3761
- await runHooks(opts.hooks, String(collection), "afterDelete", {
4507
+ await safeAfterHooks(String(collection), "afterDelete", {
3762
4508
  collection: String(collection),
3763
4509
  filter
3764
4510
  });
3765
4511
  return result;
3766
4512
  };
3767
4513
  const deleteMany = async (collection, filter, options) => {
4514
+ await runHooks(opts.hooks, String(collection), "beforeDelete", {
4515
+ collection: String(collection),
4516
+ filter
4517
+ });
3768
4518
  const result = await writeRun(
3769
4519
  collection,
3770
4520
  "mongo.deleteMany",
3771
4521
  async (r) => {
3772
4522
  const delOpts = {
3773
4523
  ...r.driverOpts,
3774
- session: r.sdk.session
4524
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {}
3775
4525
  };
3776
- return coll(collection).deleteMany(toDriverFilter(filter), delOpts);
4526
+ return await coll(collection).deleteMany(toDriverFilter(filter), delOpts);
3777
4527
  },
3778
4528
  options
3779
4529
  );
3780
4530
  invalidate(collection);
4531
+ await safeAfterHooks(String(collection), "afterDelete", {
4532
+ collection: String(collection),
4533
+ filter
4534
+ });
3781
4535
  return result;
3782
4536
  };
3783
4537
  const findOneAndDelete = async (collection, filter, options) => {
4538
+ await runHooks(opts.hooks, String(collection), "beforeDelete", {
4539
+ collection: String(collection),
4540
+ filter
4541
+ });
3784
4542
  const result = await writeRun(
3785
4543
  collection,
3786
4544
  "mongo.findOneAndDelete",
3787
4545
  async (r) => {
3788
4546
  const fOpts = {
3789
4547
  ...r.driverOpts,
3790
- session: r.sdk.session,
3791
- maxTimeMS: r.sdk.maxTimeMS,
3792
- hint: r.sdk.hint
4548
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {},
4549
+ ...r.sdk.maxTimeMS !== void 0 ? { maxTimeMS: r.sdk.maxTimeMS } : {},
4550
+ ...r.sdk.hint !== void 0 ? { hint: r.sdk.hint } : {}
3793
4551
  };
3794
4552
  return coll(collection).findOneAndDelete(
3795
4553
  toDriverFilter(filter),
@@ -3799,30 +4557,49 @@ var makeWriteOps = (ctx, readOps) => {
3799
4557
  options
3800
4558
  );
3801
4559
  invalidate(collection);
4560
+ await safeAfterHooks(String(collection), "afterDelete", {
4561
+ collection: String(collection),
4562
+ filter
4563
+ });
3802
4564
  return result;
3803
4565
  };
3804
4566
  const softDeleteOne = async (collection, filter, options) => {
4567
+ await runHooks(opts.hooks, String(collection), "beforeDelete", {
4568
+ collection: String(collection),
4569
+ filter
4570
+ });
3805
4571
  const active = mergeMongoActiveFilter(true, filter);
4572
+ const effective = stampUpdate(collection, {
4573
+ $set: { deletedAt: /* @__PURE__ */ new Date() }
4574
+ });
3806
4575
  const result = await writeRun(
3807
4576
  collection,
3808
4577
  "mongo.softDeleteOne",
3809
4578
  async (r) => {
3810
4579
  const updateOpts = {
3811
4580
  ...r.driverOpts,
3812
- session: r.sdk.session
4581
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {}
3813
4582
  };
3814
4583
  return coll(collection).updateOne(
3815
4584
  active,
3816
- { $set: { deletedAt: /* @__PURE__ */ new Date() } },
4585
+ effective,
3817
4586
  updateOpts
3818
4587
  );
3819
4588
  },
3820
4589
  options
3821
4590
  );
3822
4591
  invalidate(collection);
4592
+ await safeAfterHooks(String(collection), "afterDelete", {
4593
+ collection: String(collection),
4594
+ filter
4595
+ });
3823
4596
  return result;
3824
4597
  };
3825
4598
  const upsert = async (collection, filter, update, options) => {
4599
+ await runHooks(opts.hooks, String(collection), "beforeUpdate", {
4600
+ collection: String(collection),
4601
+ filter
4602
+ });
3826
4603
  const formatted = stampUpdate(collection, update);
3827
4604
  const result = await writeRun(
3828
4605
  collection,
@@ -3830,17 +4607,26 @@ var makeWriteOps = (ctx, readOps) => {
3830
4607
  async (r) => {
3831
4608
  const updateOpts = {
3832
4609
  ...r.driverOpts,
3833
- session: r.sdk.session,
4610
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {},
3834
4611
  upsert: true
3835
4612
  };
3836
- return coll(collection).updateOne(toDriverFilter(filter), formatted, updateOpts);
4613
+ return await coll(collection).updateOne(toDriverFilter(filter), formatted, updateOpts);
3837
4614
  },
3838
4615
  options
3839
4616
  );
3840
4617
  invalidate(collection);
4618
+ await safeAfterHooks(String(collection), "afterUpdate", {
4619
+ collection: String(collection),
4620
+ filter
4621
+ });
3841
4622
  return result;
3842
4623
  };
3843
4624
  const bulkUpsert = async (collection, operations, options) => {
4625
+ if (operations.length > MAX_BATCH_OPS) {
4626
+ throw new BadRequest(
4627
+ `bulkUpsert: too many operations (${operations.length} > ${MAX_BATCH_OPS}); split the batch`
4628
+ );
4629
+ }
3844
4630
  const writes = operations.map((op) => ({
3845
4631
  updateOne: {
3846
4632
  filter: op.filter,
@@ -3848,20 +4634,15 @@ var makeWriteOps = (ctx, readOps) => {
3848
4634
  upsert: op.upsert ?? true
3849
4635
  }
3850
4636
  }));
3851
- if (operations.length > MAX_BATCH_OPS) {
3852
- throw new BadRequest(
3853
- `bulkUpsert: too many operations (${operations.length} > ${MAX_BATCH_OPS}); split the batch`
3854
- );
3855
- }
3856
4637
  const result = await writeRun(
3857
4638
  collection,
3858
4639
  "mongo.bulkUpsert",
3859
4640
  async (r) => {
3860
4641
  const bulkOpts = {
3861
4642
  ...r.driverOpts,
3862
- session: r.sdk.session
4643
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {}
3863
4644
  };
3864
- return coll(collection).bulkWrite(writes, bulkOpts);
4645
+ return await coll(collection).bulkWrite(writes, bulkOpts);
3865
4646
  },
3866
4647
  options
3867
4648
  );
@@ -3880,9 +4661,9 @@ var makeWriteOps = (ctx, readOps) => {
3880
4661
  async (r) => {
3881
4662
  const bulkOpts = {
3882
4663
  ...r.driverOpts,
3883
- session: r.sdk.session
4664
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {}
3884
4665
  };
3885
- return coll(collection).bulkWrite(operations, bulkOpts);
4666
+ return await coll(collection).bulkWrite(operations, bulkOpts);
3886
4667
  },
3887
4668
  options
3888
4669
  );
@@ -3954,14 +4735,29 @@ var tag = (value) => {
3954
4735
  var untag = (tagged) => {
3955
4736
  switch (tagged.t) {
3956
4737
  case "oid":
4738
+ if (typeof tagged.v !== "string") {
4739
+ throw new BadRequest("paginateCursor: invalid cursor \u2014 oid value must be a string");
4740
+ }
3957
4741
  return new ObjectId(tagged.v);
3958
4742
  case "date":
4743
+ if (typeof tagged.v !== "string") {
4744
+ throw new BadRequest("paginateCursor: invalid cursor \u2014 date value must be a string");
4745
+ }
3959
4746
  return new Date(tagged.v);
3960
4747
  case "string":
4748
+ if (typeof tagged.v !== "string") {
4749
+ throw new BadRequest("paginateCursor: invalid cursor \u2014 string value expected");
4750
+ }
3961
4751
  return tagged.v;
3962
4752
  case "number":
4753
+ if (typeof tagged.v !== "number") {
4754
+ throw new BadRequest("paginateCursor: invalid cursor \u2014 number value expected");
4755
+ }
3963
4756
  return tagged.v;
3964
4757
  case "boolean":
4758
+ if (typeof tagged.v !== "boolean") {
4759
+ throw new BadRequest("paginateCursor: invalid cursor \u2014 boolean value expected");
4760
+ }
3965
4761
  return tagged.v;
3966
4762
  case "null":
3967
4763
  return null;
@@ -4018,7 +4814,7 @@ var makePaginateCursor = (ctx) => {
4018
4814
  throw new BadRequest("paginateCursor: provide either `after` or `before`, not both");
4019
4815
  }
4020
4816
  const limit = normalizePageLimit(
4021
- { page: 1, limit: config.limit },
4817
+ { page: 1, ...config.limit !== void 0 ? { limit: config.limit } : {} },
4022
4818
  config.maxLimit ?? DEFAULT_MAX_LIMIT,
4023
4819
  { limit: DEFAULT_FIND_LIMIT }
4024
4820
  ).limit;
@@ -4045,9 +4841,10 @@ var makePaginateCursor = (ctx) => {
4045
4841
  async (r) => {
4046
4842
  const findOpts = {
4047
4843
  ...r.driverOpts,
4048
- session: r.sdk.session,
4049
- maxTimeMS: r.sdk.maxTimeMS,
4050
- hint: r.sdk.hint
4844
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {},
4845
+ ...r.sdk.maxTimeMS !== void 0 ? { maxTimeMS: r.sdk.maxTimeMS } : {},
4846
+ ...r.sdk.hint !== void 0 ? { hint: r.sdk.hint } : {},
4847
+ ...r.sdk.batchSize !== void 0 ? { batchSize: r.sdk.batchSize } : {}
4051
4848
  };
4052
4849
  return client.collection(resolve(String(collection))).find(filter, findOpts).sort(effectiveSort).limit(limit + 1).toArray();
4053
4850
  },
@@ -4060,17 +4857,23 @@ var makePaginateCursor = (ctx) => {
4060
4857
  logger,
4061
4858
  db: dbLabel,
4062
4859
  drift: resolveDriftMode(config.queryOptions?.drift, opts.drift),
4063
- getSchema: opts.getSchema
4860
+ ...opts.getSchema !== void 0 ? { getSchema: opts.getSchema } : {}
4064
4861
  },
4065
4862
  String(collection),
4066
4863
  "mongo.paginateCursor",
4067
4864
  page
4068
4865
  );
4069
4866
  const boundary = backwards ? rows[limit] : page[page.length - 1];
4070
- const nextCursor = hasMore && boundary !== void 0 ? encodeCursor({
4071
- sort: config.sort,
4072
- values: sortKeys.map(([f]) => boundary[f])
4073
- }) : null;
4867
+ let nextCursor = null;
4868
+ if (hasMore && boundary !== void 0) {
4869
+ const values = sortKeys.map(([f]) => boundary[f]);
4870
+ if (values.some((v) => v === void 0)) {
4871
+ throw new BadRequest(
4872
+ "paginateCursor: boundary document is missing a sort field \u2014 every sort key must exist on every document for keyset pagination"
4873
+ );
4874
+ }
4875
+ nextCursor = encodeCursor({ sort: config.sort, values });
4876
+ }
4074
4877
  return { data: page, nextCursor, hasMore };
4075
4878
  };
4076
4879
  return { paginateCursor };
@@ -4081,14 +4884,21 @@ var makePaginateFlexible = (ctx) => {
4081
4884
  const { client, dbLabel, logger, opts, resolve, cachedAggregate } = ctx;
4082
4885
  const paginateFlexible = async (collection, baseFilter, config = {}) => {
4083
4886
  const normalized = normalizePageLimit(
4084
- { page: config.page, limit: config.limit },
4085
- config.maxLimit ?? DEFAULT_MAX_LIMIT
4887
+ {
4888
+ ...config.page !== void 0 ? { page: config.page } : {},
4889
+ ...config.limit !== void 0 ? { limit: config.limit } : {}
4890
+ },
4891
+ config.maxLimit ?? DEFAULT_MAX_LIMIT,
4892
+ // Same default as `findMany`/`paginateCursor` — the two pagination
4893
+ // APIs used to disagree silently (10 vs 100).
4894
+ { limit: DEFAULT_FIND_LIMIT }
4086
4895
  );
4087
4896
  const dataStages = [
4088
4897
  ...config.prePipeline ?? [],
4089
4898
  ...config.sort && Object.keys(config.sort).length > 0 ? [{ $sort: config.sort }] : [],
4090
4899
  { $skip: normalized.offset },
4091
4900
  { $limit: normalized.limit },
4901
+ ...config.projection !== void 0 ? [{ $project: config.projection }] : [],
4092
4902
  ...config.postPipeline ?? []
4093
4903
  ];
4094
4904
  const pipeline = [
@@ -4104,30 +4914,32 @@ var makePaginateFlexible = (ctx) => {
4104
4914
  collection,
4105
4915
  opName: "mongo.paginateFlexible",
4106
4916
  pipeline,
4107
- options: config.queryOptions,
4917
+ ...config.queryOptions !== void 0 ? { options: config.queryOptions } : {},
4108
4918
  execute: async (r) => {
4109
4919
  const aggOptions = {
4110
4920
  ...r.driverOpts,
4111
- session: r.sdk.session,
4112
- maxTimeMS: r.sdk.maxTimeMS,
4113
- hint: r.sdk.hint,
4114
- batchSize: r.sdk.batchSize
4921
+ ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {},
4922
+ ...r.sdk.maxTimeMS !== void 0 ? { maxTimeMS: r.sdk.maxTimeMS } : {},
4923
+ ...r.sdk.hint !== void 0 ? { hint: r.sdk.hint } : {},
4924
+ ...r.sdk.batchSize !== void 0 ? { batchSize: r.sdk.batchSize } : {}
4115
4925
  };
4116
4926
  const rows = await client.collection(resolve(String(collection))).aggregate(pipeline, aggOptions).toArray();
4117
4927
  const row = rows[0];
4118
4928
  const data = row?.data ?? [];
4119
4929
  const totalCount = row?.totalCount?.[0]?.count ?? 0;
4120
- checkDocsDrift(
4121
- {
4122
- logger,
4123
- db: dbLabel,
4124
- drift: resolveDriftMode(config.queryOptions?.drift, opts.drift),
4125
- getSchema: opts.getSchema
4126
- },
4127
- String(collection),
4128
- "mongo.paginateFlexible",
4129
- data
4130
- );
4930
+ if (config.projection === void 0) {
4931
+ checkDocsDrift(
4932
+ {
4933
+ logger,
4934
+ db: dbLabel,
4935
+ drift: resolveDriftMode(config.queryOptions?.drift, opts.drift),
4936
+ ...opts.getSchema !== void 0 ? { getSchema: opts.getSchema } : {}
4937
+ },
4938
+ String(collection),
4939
+ "mongo.paginateFlexible",
4940
+ data
4941
+ );
4942
+ }
4131
4943
  return buildPaginationResult(data, totalCount, normalized.page, normalized.limit);
4132
4944
  }
4133
4945
  });
@@ -4155,9 +4967,12 @@ var makePaginationOps = (client, dbLabel, logger, opts) => {
4155
4967
  };
4156
4968
 
4157
4969
  // src/service/schema-ops.ts
4158
- var indexKeyOf = (key) => JSON.stringify(
4159
- Object.keys(key).sort().map((k) => [k, key[k]])
4160
- );
4970
+ var optionSignature = (o) => JSON.stringify([
4971
+ Boolean(o?.unique),
4972
+ Boolean(o?.sparse),
4973
+ typeof o?.expireAfterSeconds === "number" ? o.expireAfterSeconds : null
4974
+ ]);
4975
+ var indexKeyOf = (key, options) => `${JSON.stringify(Object.keys(key).map((k) => [k, key[k]]))}|${optionSignature(options)}`;
4161
4976
  var makeSchemaOps = (client, _logger, opts) => {
4162
4977
  const createSchema = async (collection, createCollectionOptions) => {
4163
4978
  const physical = opts.resolveCollectionName(collection);
@@ -4165,8 +4980,10 @@ var makeSchemaOps = (client, _logger, opts) => {
4165
4980
  const validator = def?.schema ? toMongoValidator(def.schema) : void 0;
4166
4981
  try {
4167
4982
  await client.createCollection(physical, {
4168
- ...validator ? { validator } : {},
4169
- ...createCollectionOptions ?? {}
4983
+ // Caller options FIRST, derived validator LAST — a stale `validator`
4984
+ // key in the options must not clobber the schema-derived one.
4985
+ ...createCollectionOptions ?? {},
4986
+ ...validator ? { validator } : {}
4170
4987
  });
4171
4988
  } catch (err) {
4172
4989
  if (err?.code === 48) {
@@ -4211,9 +5028,11 @@ var makeSchemaOps = (client, _logger, opts) => {
4211
5028
  const handle = client.collection(physical);
4212
5029
  const existing = (await handle.listIndexes().toArray()).filter((i) => i.name !== "_id_");
4213
5030
  const existingByKey = new Map(
4214
- existing.map((i) => [indexKeyOf(i.key), i.name])
5031
+ existing.map((i) => [indexKeyOf(i.key, i), i.name])
5032
+ );
5033
+ const declaredByKey = new Map(
5034
+ declared.map((d) => [indexKeyOf(d.key, d.options), d])
4215
5035
  );
4216
- const declaredByKey = new Map(declared.map((d) => [indexKeyOf(d.key), d]));
4217
5036
  const created = [];
4218
5037
  const dropped = [];
4219
5038
  for (const [key] of declaredByKey) {
@@ -4225,7 +5044,9 @@ var makeSchemaOps = (client, _logger, opts) => {
4225
5044
  }
4226
5045
  }
4227
5046
  const after = (await handle.listIndexes().toArray()).filter((i) => i.name !== "_id_");
4228
- const afterByKey = new Map(after.map((i) => [indexKeyOf(i.key), i.name]));
5047
+ const afterByKey = new Map(
5048
+ after.map((i) => [indexKeyOf(i.key, i), i.name])
5049
+ );
4229
5050
  for (const [key, spec] of declaredByKey) {
4230
5051
  if (!existingByKey.has(key)) {
4231
5052
  created.push(spec.options?.name ?? afterByKey.get(key) ?? JSON.stringify(spec.key));
@@ -4244,16 +5065,22 @@ var makeSchemaOps = (client, _logger, opts) => {
4244
5065
 
4245
5066
  // src/service/transaction.ts
4246
5067
  var makeTransactionOps = (client, handle, logger, _config, opts) => {
5068
+ let warnedUnsupported = false;
5069
+ let journalIndexReady = false;
5070
+ const MIGRATE_LEASE_MS = 12e4;
4247
5071
  const transaction = async (fn, options) => {
4248
5072
  const session = client.startSession();
4249
5073
  try {
4250
5074
  return await session.withTransaction(async () => fn(session), options);
4251
5075
  } catch (err) {
4252
- const message = err instanceof Error ? err.message : "";
4253
- const code = err?.code;
4254
- const unsupported = code === 20 || /Transaction numbers are only allowed/i.test(message) || /does not support retryable writes/i.test(message);
4255
- if (unsupported) {
4256
- logger.warn?.({ code }, "transactions unsupported, falling back to non-transactional");
5076
+ if (isTransactionUnsupportedError(err)) {
5077
+ if (!warnedUnsupported) {
5078
+ warnedUnsupported = true;
5079
+ logger.warn?.(
5080
+ { error: err instanceof Error ? err.message : String(err) },
5081
+ "transactions unsupported on this deployment, falling back to non-transactional"
5082
+ );
5083
+ }
4257
5084
  return fn(null);
4258
5085
  }
4259
5086
  if (opts.wrapMongoErrors) {
@@ -4265,34 +5092,105 @@ var makeTransactionOps = (client, handle, logger, _config, opts) => {
4265
5092
  }
4266
5093
  };
4267
5094
  const migrate = async (name, fn, filePath) => {
4268
- const journal = handle.collection(
4269
- opts.migrationCollectionPhysical
4270
- );
5095
+ const journal = handle.collection(opts.migrationCollectionPhysical);
5096
+ if (!journalIndexReady) {
5097
+ try {
5098
+ await journal.createIndex({ name: 1 }, { unique: true });
5099
+ } catch {
5100
+ }
5101
+ journalIndexReady = true;
5102
+ }
5103
+ const isDupKeyError = (err) => {
5104
+ const code = err?.code;
5105
+ if (code === 11e3 || code === 11001) return true;
5106
+ return /E11000 duplicate key/.test(String(err?.message ?? ""));
5107
+ };
4271
5108
  await transaction(async (session) => {
4272
5109
  const sessionOpts = session ? { session } : {};
4273
- const claim = await journal.findOneAndUpdate(
4274
- { name },
5110
+ const now = /* @__PURE__ */ new Date();
5111
+ const expiresAt = new Date(Date.now() + MIGRATE_LEASE_MS);
5112
+ const stole = await journal.findOneAndUpdate(
4275
5113
  {
4276
- $setOnInsert: {
4277
- name,
4278
- appliedAt: /* @__PURE__ */ new Date(),
5114
+ name,
5115
+ status: { $exists: true, $nin: ["applied"] },
5116
+ $or: [
5117
+ { expiresAt: { $lt: now } },
5118
+ {
5119
+ expiresAt: { $exists: false },
5120
+ appliedAt: { $lt: new Date(now.getTime() - MIGRATE_LEASE_MS) }
5121
+ }
5122
+ ]
5123
+ },
5124
+ {
5125
+ $set: {
5126
+ status: "running",
5127
+ appliedAt: now,
5128
+ expiresAt,
4279
5129
  ...filePath ? { file: filePath } : {}
4280
5130
  }
4281
5131
  },
4282
- { upsert: true, returnDocument: "before", ...sessionOpts }
5132
+ { returnDocument: "before", ...sessionOpts }
4283
5133
  );
4284
- if (claim) {
4285
- logger.info({ name }, "migration already applied, skipping");
5134
+ if (stole !== null) {
5135
+ await runClaimed(session);
4286
5136
  return;
4287
5137
  }
5138
+ try {
5139
+ await journal.insertOne(
5140
+ {
5141
+ name,
5142
+ appliedAt: now,
5143
+ status: "running",
5144
+ expiresAt,
5145
+ ...filePath ? { file: filePath } : {}
5146
+ },
5147
+ sessionOpts
5148
+ );
5149
+ } catch (err) {
5150
+ if (!isDupKeyError(err)) throw err;
5151
+ const existing = await journal.findOne(
5152
+ { name },
5153
+ { projection: { status: 1 }, ...sessionOpts }
5154
+ );
5155
+ const status = existing?.status;
5156
+ if (status === void 0 || status === "applied") {
5157
+ logger.info({ name }, "migration already applied, skipping");
5158
+ } else {
5159
+ logger.warn({ name }, "migration claim held by another runner, skipping");
5160
+ }
5161
+ return;
5162
+ }
5163
+ await runClaimed(session);
5164
+ });
5165
+ async function runClaimed(session) {
5166
+ const sessionOpts = session ? { session } : {};
5167
+ const heartbeat = session === null ? setInterval(() => {
5168
+ void journal.updateOne(
5169
+ { name, status: "running" },
5170
+ { $set: { expiresAt: new Date(Date.now() + MIGRATE_LEASE_MS) } }
5171
+ ).catch(() => {
5172
+ });
5173
+ }, MIGRATE_LEASE_MS / 2) : void 0;
5174
+ heartbeat?.unref?.();
4288
5175
  try {
4289
5176
  await fn(session);
5177
+ const res = await journal.updateOne(
5178
+ { name, status: "running" },
5179
+ { $set: { status: "applied" } },
5180
+ sessionOpts
5181
+ );
5182
+ if (res?.matchedCount === 0) {
5183
+ await journal.insertOne({ name, appliedAt: /* @__PURE__ */ new Date(), status: "applied" }, sessionOpts).catch(() => {
5184
+ });
5185
+ }
4290
5186
  } catch (err) {
4291
5187
  await journal.deleteOne({ name }, sessionOpts).catch(() => {
4292
5188
  });
4293
5189
  throw err;
5190
+ } finally {
5191
+ if (heartbeat !== void 0) clearInterval(heartbeat);
4294
5192
  }
4295
- });
5193
+ }
4296
5194
  };
4297
5195
  return { transaction, migrate };
4298
5196
  };
@@ -4323,27 +5221,27 @@ var buildManager = (deps2, dbKey, dbName, client) => {
4323
5221
  const crud = makeCrudOps(handle, dbName, logger, {
4324
5222
  resolveCollectionName,
4325
5223
  wrapMongoErrors: deps2.wrapMongoErrors,
4326
- cache: sharedCache,
5224
+ ...sharedCache !== void 0 ? { cache: sharedCache } : {},
4327
5225
  dedupeReads,
4328
5226
  inFlight,
4329
5227
  hooks,
4330
5228
  timestamps,
4331
- drift: deps2.drift,
4332
- getSchema: (logical) => getDefinition(logical)?.schema
5229
+ ...deps2.drift !== void 0 ? { drift: deps2.drift } : {},
5230
+ ...getDefinition !== void 0 ? { getSchema: (logical) => getDefinition(logical)?.schema } : {}
4333
5231
  });
4334
5232
  const pagination = makePaginationOps(handle, dbName, logger, {
4335
5233
  resolveCollectionName,
4336
5234
  wrapMongoErrors: deps2.wrapMongoErrors,
4337
- drift: deps2.drift,
4338
- getSchema: (logical) => getDefinition(logical)?.schema,
4339
- cache: sharedCache,
5235
+ ...deps2.drift !== void 0 ? { drift: deps2.drift } : {},
5236
+ ...getDefinition !== void 0 ? { getSchema: (logical) => getDefinition(logical)?.schema } : {},
5237
+ ...sharedCache !== void 0 ? { cache: sharedCache } : {},
4340
5238
  dedupeReads,
4341
5239
  inFlight
4342
5240
  });
4343
5241
  const aggregation = makeAggregationOps(handle, dbName, logger, {
4344
5242
  resolveCollectionName,
4345
5243
  wrapMongoErrors: deps2.wrapMongoErrors,
4346
- cache: sharedCache,
5244
+ ...sharedCache !== void 0 ? { cache: sharedCache } : {},
4347
5245
  dedupeReads,
4348
5246
  inFlight
4349
5247
  });
@@ -4356,7 +5254,7 @@ var buildManager = (deps2, dbKey, dbName, client) => {
4356
5254
  findMany: async (logical, filter, options) => {
4357
5255
  const physical = resolveCollectionName(logical);
4358
5256
  return handle.collection(physical).find(filter, {
4359
- projection: options?.projection,
5257
+ ...options?.projection !== void 0 ? { projection: options.projection } : {},
4360
5258
  limit: options?.limit ?? 1e4
4361
5259
  }).toArray();
4362
5260
  }
@@ -4396,11 +5294,11 @@ var createMongoService = (dbClients, configOverrides = {}) => {
4396
5294
  dbClients,
4397
5295
  config,
4398
5296
  logger,
4399
- sharedCache,
5297
+ ...sharedCache !== void 0 ? { sharedCache } : {},
4400
5298
  dedupeReads,
4401
5299
  inFlight,
4402
5300
  wrapMongoErrors: runtime.wrapMongoErrors,
4403
- drift: configOverrides.drift,
5301
+ ...configOverrides.drift !== void 0 ? { drift: configOverrides.drift } : {},
4404
5302
  collectionRefs,
4405
5303
  db
4406
5304
  },
@@ -4437,11 +5335,27 @@ var createMongoService = (dbClients, configOverrides = {}) => {
4437
5335
  };
4438
5336
 
4439
5337
  // src/shared/merge-filters.ts
5338
+ var isOperatorDoc = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
4440
5339
  var mergeMongoFilters = (...parts) => {
4441
5340
  const out = {};
4442
5341
  for (const part of parts) {
4443
5342
  if (!part) continue;
4444
- Object.assign(out, part);
5343
+ for (const [key, value] of Object.entries(part)) {
5344
+ if (!(key in out)) {
5345
+ out[key] = value;
5346
+ continue;
5347
+ }
5348
+ if (key === "$and" || key === "$or") {
5349
+ const prev2 = out[key];
5350
+ out[key] = [
5351
+ ...Array.isArray(prev2) ? prev2 : [prev2],
5352
+ ...Array.isArray(value) ? value : [value]
5353
+ ];
5354
+ continue;
5355
+ }
5356
+ const prev = out[key];
5357
+ out[key] = isOperatorDoc(prev) && isOperatorDoc(value) ? { ...prev, ...value } : value;
5358
+ }
4445
5359
  }
4446
5360
  return out;
4447
5361
  };
@@ -4520,6 +5434,6 @@ var createCachedAsyncFactory = (factory, options = {}) => {
4520
5434
  };
4521
5435
  };
4522
5436
 
4523
- export { AppError, BadRequest, CacheInvalidator, DEFAULT_FIND_LIMIT, DEFAULT_MAX_LIMIT, DataLoader, DomainError, ERROR_HTTP_STATUS, HOOK_NAMES, HotCache, InFlight, InfraError, LRU, MAX_BATCH_OPS, QueryBuilder, QueryCache, TRANSIENT_MONGO_ERROR_CODES, belongsTo, buildKeysetFilter, buildMongoActiveFilter, buildPaginationResult, cacheCollectionKey, canonicalKey, createCachedAsyncFactory, createCachedFactory, createConsoleLogger, createHotCache, createMongoCapabilitiesStore, createMongoMigrationRunner, createMongoService, createMongoToolkit, createNoopLogger, createRepository, createResolveCollectionName, decodeCursor, defineCollection, defineCollections, encodeCursor, hasMany, hashString, httpStatusForError, isAppError, isDomainError, isInfraError, isMappedMongoError, isMongoDuplicateKeyError, isMongoTransientError, manyToMany, mapMongoDriverError, mergeMongoActiveFilter, mergeMongoFilters, normalizeMongoUrl, normalizePageLimit, optional, probeMongoCapabilities, runHooks, s, serializeError, sleep, sleepJittered, stableHash, stableStringify, stripDocumentId, stripPrimaryKey, toMongoSchema, toMongoValidator, validateDoc, withDefault, withGracefulMongoTransaction, withRetry, withTimeout, withTransaction };
5437
+ export { AppError, BadRequest, CacheInvalidator, DEFAULT_FIND_LIMIT, DEFAULT_MAX_LIMIT, DataLoader, DomainError, ERROR_HTTP_STATUS, HOOK_NAMES, HotCache, InFlight, InfraError, LRU, MAX_BATCH_OPS, QueryBuilder, QueryCache, TRANSIENT_MONGO_ERROR_CODES, belongsTo, buildKeysetFilter, buildMongoActiveFilter, buildPaginationResult, cacheCollectionKey, canonicalKey, createCachedAsyncFactory, createCachedFactory, createConsoleLogger, createHotCache, createMongoCapabilitiesStore, createMongoMigrationRunner, createMongoService, createMongoToolkit, createNoopLogger, createRepository, createResolveCollectionName, decodeCursor, defineCollection, defineCollections, encodeCursor, hasMany, hashString, httpStatusForError, isAppError, isDomainError, isInfraError, isMappedMongoError, isMongoDuplicateKeyError, isMongoTransientError, isTransactionUnsupportedError, manyToMany, mapMongoDriverError, mergeMongoActiveFilter, mergeMongoFilters, normalizeMongoUrl, normalizePageLimit, optional, probeMongoCapabilities, runHooks, s, serializeError, sleep, sleepJittered, stableHash, stableStringify, stripDocumentId, stripPrimaryKey, toMongoSchema, toMongoValidator, validateDoc, withDefault, withGracefulMongoTransaction, withRetry, withTimeout, withTransaction };
4524
5438
  //# sourceMappingURL=index.js.map
4525
5439
  //# sourceMappingURL=index.js.map