@ignex/ninox 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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';
@@ -56,14 +56,6 @@ var probeMongoCapabilities = async (client) => {
56
56
  return { probed: false, transactionsSupported: false };
57
57
  }
58
58
  };
59
- var mongoTransactionsEnabled = (store, options = {}) => {
60
- const env = options.envOverride ?? readMongoTransactionsEnvOverride();
61
- if (env !== void 0) return env;
62
- const state = store.get();
63
- if (state.probed) return state.transactionsSupported;
64
- if (options.urlHint) return /replicaSet=/.test(options.urlHint);
65
- return false;
66
- };
67
59
 
68
60
  // src/errors/classes.ts
69
61
  var ERROR_HTTP_STATUS = {
@@ -138,8 +130,14 @@ var TRANSIENT_MONGO_ERROR_CODES = Object.freeze(
138
130
  // ShutdownInProgress
139
131
  189,
140
132
  // PrimarySteppedDown
133
+ 251,
134
+ // NoSuchTransaction (stepdown mid-transaction)
135
+ 262,
136
+ // ExceededTimeLimit (part of the retryable transaction set)
141
137
  9001,
142
138
  // SocketException
139
+ 10058,
140
+ // NotWritablePrimary (legacy server code)
143
141
  10107,
144
142
  // NotWritablePrimary
145
143
  11600,
@@ -152,10 +150,13 @@ var TRANSIENT_MONGO_ERROR_CODES = Object.freeze(
152
150
  // NotPrimaryOrSecondary
153
151
  ])
154
152
  );
155
- var TRANSIENT_NAME = /MongoNetworkError|PoolClearedError|MongoWriteConcernError|MongoNotPrimaryError|MongoNodeIsRecoveringError|MongoNetworkTimeoutError/;
153
+ var TRANSIENT_NAME = /MongoNetworkError|PoolClearedError|MongoWriteConcernError|MongoNotPrimaryError|MongoNodeIsRecoveringError|MongoNetworkTimeoutError|MongoServerSelectionError/;
156
154
  var isMongoTransientError = (error) => {
157
155
  if (!error || typeof error !== "object") return false;
158
156
  const e = error;
157
+ if (typeof e.hasErrorLabel === "function" && e.hasErrorLabel("TransientTransactionError")) {
158
+ return true;
159
+ }
159
160
  if (typeof e.name === "string" && TRANSIENT_NAME.test(e.name)) return true;
160
161
  if (typeof e.code === "number" && TRANSIENT_MONGO_ERROR_CODES.has(e.code)) return true;
161
162
  return false;
@@ -167,6 +168,42 @@ var isMongoDuplicateKeyError = (error) => {
167
168
  if (typeof e.message === "string" && /E11000|duplicate key/i.test(e.message)) return true;
168
169
  return false;
169
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
+ };
170
207
 
171
208
  // src/errors/driver-map.ts
172
209
  var extractValidationPaths = (errInfo) => {
@@ -195,6 +232,7 @@ var addValidationContext = (context, errInfo) => {
195
232
  var isBulkWriteError = (error) => typeof error === "object" && error !== null && "writeErrors" in error && Array.isArray(error.writeErrors);
196
233
  var mapMongoDriverError = (error, ctx = {}) => {
197
234
  if (error instanceof AppError) return error;
235
+ if (!isMongoDriverError(error)) return error;
198
236
  const context = { ...ctx };
199
237
  if (isMongoDuplicateKeyError(error)) {
200
238
  const e = error;
@@ -207,7 +245,7 @@ var mapMongoDriverError = (error, ctx = {}) => {
207
245
  if (first?.code === 11e3) {
208
246
  if (first.keyPattern) context.keyPattern = first.keyPattern;
209
247
  if (first.keyValue) context.keyValue = first.keyValue;
210
- return new DomainError("DUPLICATE_KEY", first.errmsg ?? "Duplicate key error", context);
248
+ return new DomainError("DUPLICATE_KEY", "Duplicate key error", context);
211
249
  }
212
250
  if (first?.code === 121) {
213
251
  addValidationContext(context, first.errInfo);
@@ -252,7 +290,47 @@ var serializeError = (err, ctx = {}) => {
252
290
  var cloneDeep = (value) => {
253
291
  if (value instanceof Date) return new Date(value.getTime());
254
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
+ }
255
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
+ }
256
334
  if (Array.isArray(value)) return value.map((item) => cloneDeep(item));
257
335
  if (value !== null && typeof value === "object") {
258
336
  const out = {};
@@ -265,7 +343,10 @@ var cloneDeep = (value) => {
265
343
  };
266
344
 
267
345
  // src/utils/hash.ts
268
- 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";
269
350
  var toPrimitive = (v) => {
270
351
  if (v === void 0 || v === null) return null;
271
352
  if (typeof v === "bigint") return v.toString();
@@ -290,11 +371,15 @@ var stableStringify = (value) => {
290
371
  return JSON.stringify(visit(value));
291
372
  };
292
373
  var hashString = (input) => {
293
- let hash = 5381;
374
+ let h1 = 5381;
375
+ let h2 = 2166136261;
294
376
  for (let i = 0; i < input.length; i++) {
295
- 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;
296
381
  }
297
- return (hash >>> 0).toString(36);
382
+ return `${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`;
298
383
  };
299
384
  var stableHash = (value) => hashString(stableStringify(value));
300
385
 
@@ -302,11 +387,13 @@ var stableHash = (value) => hashString(stableStringify(value));
302
387
  var LRU = class {
303
388
  max;
304
389
  map = /* @__PURE__ */ new Map();
390
+ onEvict;
305
391
  /** Number of entries evicted to make room for new ones (capacity evictions only). */
306
392
  evictions = 0;
307
393
  constructor(options) {
308
394
  if (options.max <= 0) throw new Error("LRU max must be > 0");
309
395
  this.max = options.max;
396
+ this.onEvict = options.onEvict;
310
397
  }
311
398
  get size() {
312
399
  return this.map.size;
@@ -328,10 +415,16 @@ var LRU = class {
328
415
  if (this.map.has(key)) this.map.delete(key);
329
416
  this.map.set(key, value);
330
417
  if (this.map.size > this.max) {
331
- const oldest = this.map.keys().next().value;
332
- if (oldest !== void 0) {
333
- 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);
334
423
  this.evictions++;
424
+ try {
425
+ this.onEvict?.(evictedKey, evictedValue);
426
+ } catch {
427
+ }
335
428
  }
336
429
  }
337
430
  return this;
@@ -362,9 +455,20 @@ var InFlight = class {
362
455
  run(key, fn) {
363
456
  const existing = this.pending.get(key);
364
457
  if (existing) return existing;
365
- const promise = fn().finally(() => {
366
- this.pending.delete(key);
367
- });
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
+ );
368
472
  this.pending.set(key, promise);
369
473
  return promise;
370
474
  }
@@ -372,24 +476,50 @@ var InFlight = class {
372
476
  this.pending.clear();
373
477
  }
374
478
  };
375
- var estimateSize = (value) => {
479
+ var OVERHEAD_PER_KEY = 8;
480
+ var estimateSize = (value, seen) => {
376
481
  if (value === null || value === void 0) return 0;
377
482
  if (typeof value === "string") return value.length * 2;
378
483
  if (typeof value === "number" || typeof value === "boolean") return 8;
484
+ if (typeof value === "bigint") return 16;
485
+ if (typeof value !== "object") return 0;
379
486
  if (value instanceof ObjectId) return 16;
380
487
  if (value instanceof Date) return 8;
381
- if (Array.isArray(value)) return value.reduce((sum, v) => sum + estimateSize(v), 0);
382
- if (typeof value === "object") {
383
- let sum = 0;
384
- for (const [k, v] of Object.entries(value)) {
385
- 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);
386
493
  }
387
- 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;
500
+ }
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);
388
517
  }
389
- return 0;
518
+ return sum;
390
519
  };
391
520
 
392
521
  // src/cache/hot-cache/ticker.ts
522
+ var MAX_TICK_INFLIGHT = 64;
393
523
  var RefreshTicker = class {
394
524
  constructor(host, tickIntervalMs) {
395
525
  this.host = host;
@@ -424,17 +554,26 @@ var RefreshTicker = class {
424
554
  this.timer = void 0;
425
555
  }
426
556
  }
427
- /** 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
+ */
428
563
  tick() {
429
564
  const now = Date.now();
565
+ let budget = MAX_TICK_INFLIGHT;
430
566
  for (const q of this.host.queries().values()) {
431
567
  if (q.refreshIntervalMs <= 0) continue;
432
568
  for (const [key, entry] of [...q.lru.entries()]) {
433
569
  if (entry.nextRefreshAt <= now) {
570
+ if (budget <= 0) return;
571
+ budget--;
572
+ entry.nextRefreshAt = now + q.refreshIntervalMs;
434
573
  q.refreshes++;
435
574
  void this.host.fetch(q, key, entry.args, q.gen).catch((err) => {
436
575
  const current = q.lru.peek(key);
437
- if (current) current.nextRefreshAt = now + q.refreshIntervalMs;
576
+ if (current) current.nextRefreshAt = Date.now();
438
577
  this.host.logger.warn?.(
439
578
  {
440
579
  error: err instanceof Error ? err.message : String(err),
@@ -468,6 +607,15 @@ var RefreshTicker = class {
468
607
 
469
608
  // src/cache/hot-cache/types.ts
470
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
+ };
471
619
 
472
620
  // src/utils/watch-errors.ts
473
621
  var isPermanentWatchError = (message) => (
@@ -482,6 +630,21 @@ var isPermanentWatchError = (message) => (
482
630
  );
483
631
 
484
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
+ };
485
648
  var WatchCoordinator = class {
486
649
  constructor(host) {
487
650
  this.host = host;
@@ -489,6 +652,8 @@ var WatchCoordinator = class {
489
652
  streams = /* @__PURE__ */ new Map();
490
653
  failedStreams = /* @__PURE__ */ new Set();
491
654
  stopped = true;
655
+ /** Guards against stacking reconnect timers when several accessors lag. */
656
+ reopenTimer;
492
657
  /** Number of currently open change streams. */
493
658
  get size() {
494
659
  return this.streams.size;
@@ -501,6 +666,7 @@ var WatchCoordinator = class {
501
666
  /** Close every watcher and clear failure bookkeeping (idempotent). */
502
667
  async stop() {
503
668
  this.stopped = true;
669
+ if (this.reopenTimer) clearTimeout(this.reopenTimer);
504
670
  const closes = [...this.streams.values()].map((stream) => {
505
671
  try {
506
672
  return Promise.resolve(stream.close()).catch(() => {
@@ -515,14 +681,32 @@ var WatchCoordinator = class {
515
681
  }
516
682
  openWatchStreams() {
517
683
  const seen = /* @__PURE__ */ new Set();
684
+ let deferred = false;
518
685
  for (const q of this.host.queries().values()) {
519
686
  for (const ref of q.config.watch ?? []) {
520
- 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}`;
521
693
  if (seen.has(key) || this.streams.has(key)) continue;
522
694
  seen.add(key);
523
- 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
+ });
524
701
  }
525
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
+ }
526
710
  }
527
711
  /**
528
712
  * Long-lived change-stream consumer for one collection. Each data change
@@ -536,11 +720,19 @@ var WatchCoordinator = class {
536
720
  async watchLoop(key, ref) {
537
721
  let backoff = 0;
538
722
  while (!this.stopped) {
723
+ let connectedAt = Date.now();
539
724
  try {
540
- const stream = ref.db.collection(ref.collection).watch([]);
541
- 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
+ );
542
733
  });
543
734
  this.streams.set(key, stream);
735
+ connectedAt = Date.now();
544
736
  if (backoff > 0) {
545
737
  this.host.logger.warn?.(
546
738
  { collection: ref.collection },
@@ -549,10 +741,13 @@ var WatchCoordinator = class {
549
741
  this.host.invalidateCollection(ref.collection);
550
742
  }
551
743
  const iterator = stream[Symbol.asyncIterator]();
552
- for await (const _change of iterator) {
553
- 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);
554
748
  }
555
749
  } catch (err) {
750
+ if (Date.now() - connectedAt > 6e4) backoff = 0;
556
751
  const message = err instanceof Error ? err.message : String(err);
557
752
  this.host.logger.warn?.(
558
753
  { error: message, collection: ref.collection },
@@ -589,7 +784,9 @@ var WatchCoordinator = class {
589
784
  const set = /* @__PURE__ */ new Set();
590
785
  for (const q of this.host.queries().values()) {
591
786
  for (const ref of q.config.watch ?? []) {
592
- 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}`);
593
790
  }
594
791
  }
595
792
  return set;
@@ -630,10 +827,14 @@ var HotCache = class {
630
827
  this.watcher = new WatchCoordinator({
631
828
  queries: () => this.queries,
632
829
  invalidateCollection: (collection) => this.invalidateCollection(collection),
830
+ invalidateDocument: (collection, id) => this.invalidateIds(collection, [id]),
633
831
  isReplica: () => this._mode === "replica",
634
832
  fallbackToStandalone: () => {
635
833
  this._mode = "standalone";
636
- if (this.refreshEnabled) this.ticker.start();
834
+ if (this.refreshEnabled && !this.ticker.running) {
835
+ this.ticker.start();
836
+ this.ticker.warnUnboundedStaleness();
837
+ }
637
838
  },
638
839
  logger: this.logger
639
840
  });
@@ -676,25 +877,26 @@ var HotCache = class {
676
877
  refreshIntervalMs: config.refreshIntervalMs ?? 0,
677
878
  clone: config.clone ?? false,
678
879
  ...config.maxValueBytes !== void 0 ? { maxValueBytes: config.maxValueBytes } : {},
880
+ hasIdWatch: (config.watch ?? []).some((ref) => typeof ref.idsOf === "function"),
679
881
  gen: 0,
680
882
  hits: 0,
681
883
  misses: 0,
682
884
  refreshes: 0,
683
885
  loadErrors: 0,
684
886
  sizeSkips: 0,
887
+ idDrops: 0,
685
888
  warnedSize: false
686
889
  };
687
890
  this.queries.set(name, query);
688
891
  if (this._mode === "replica" && !this.stopped) {
689
892
  this.watcher.start();
690
893
  }
691
- return {
692
- get: (...args) => this.get(name, ...args),
693
- invalidate: (...args) => {
694
- if (args.length === 0) this.invalidate(name);
695
- else this.invalidateParams(name, ...args);
696
- }
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);
697
898
  };
899
+ return { get, invalidate };
698
900
  }
699
901
  /**
700
902
  * Read-through read by dynamic name lookup. Call parameters are hashed into
@@ -716,7 +918,7 @@ var HotCache = class {
716
918
  q.lru.delete(key);
717
919
  }
718
920
  q.misses++;
719
- return this.fetch(q, key, args, q.gen);
921
+ return await this.fetch(q, key, args, q.gen);
720
922
  }
721
923
  /** Drop every cached entry for a registered query. */
722
924
  invalidate(name) {
@@ -749,6 +951,48 @@ var HotCache = class {
749
951
  }
750
952
  }
751
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
+ }
752
996
  /** Drop every cached entry for every query. */
753
997
  clear() {
754
998
  for (const q of this.queries.values()) {
@@ -758,9 +1002,10 @@ var HotCache = class {
758
1002
  }
759
1003
  /**
760
1004
  * Runtime observability snapshot: total queries/entries plus per-query
761
- * counters (`hits`, `misses`, background `refreshes`, `loadErrors`, and
762
- * `sizeSkips` for entries dropped by `maxValueBytes`). Useful for dashboards
763
- * 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.
764
1009
  */
765
1010
  stats() {
766
1011
  const perQuery = {};
@@ -772,6 +1017,7 @@ var HotCache = class {
772
1017
  refreshes: q.refreshes,
773
1018
  loadErrors: q.loadErrors,
774
1019
  sizeSkips: q.sizeSkips,
1020
+ idDrops: q.idDrops,
775
1021
  evictions: q.lru.evictions
776
1022
  };
777
1023
  }
@@ -780,12 +1026,15 @@ var HotCache = class {
780
1026
  /**
781
1027
  * Probe replica support once, then start change-stream watchers (replica) or
782
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).
783
1032
  */
784
1033
  async start() {
785
1034
  if (this.disposed) return this._mode;
786
1035
  if (this.started) return this._mode;
787
1036
  if (!this.startPromise) {
788
- this.startPromise = this.runStart();
1037
+ this.startPromise = this.runStart().catch(this.forgetFailedStart);
789
1038
  }
790
1039
  return this.startPromise;
791
1040
  }
@@ -807,6 +1056,17 @@ var HotCache = class {
807
1056
  this._mode = "unknown";
808
1057
  }
809
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
+ };
810
1070
  async runStart() {
811
1071
  this.stopped = false;
812
1072
  const supported = this.options.mode === "replica" ? true : this.options.mode === "standalone" ? false : await (this.options.probe ?? this.defaultProbe)();
@@ -830,20 +1090,34 @@ var HotCache = class {
830
1090
  ensureStarted() {
831
1091
  if (this.disposed || this.started || this.startAttempted) return;
832
1092
  this.startAttempted = true;
833
- 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;
834
1097
  this.logger.warn?.(
835
- { error: err instanceof Error ? err.message : String(err) },
1098
+ { error: wrapped.message, stack: wrapped.stack, trigger: origin.stack },
836
1099
  "hot cache start failed; reads continue without auto-refresh"
837
1100
  );
838
- });
1101
+ };
1102
+ this.start().catch(reportStartFailure);
839
1103
  }
840
1104
  defaultProbe = async () => {
841
1105
  for (const q of this.queries.values()) {
842
1106
  for (const ref of q.config.watch ?? []) {
843
- 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);
844
1118
  if (!caps.probed) {
845
1119
  this.logger.warn?.(
846
- { db: ref.db.databaseName },
1120
+ { db: db.databaseName },
847
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"
848
1122
  );
849
1123
  return false;
@@ -854,17 +1128,18 @@ var HotCache = class {
854
1128
  return false;
855
1129
  };
856
1130
  fetch(q, key, args, gen) {
857
- const dedupeKey = `${q.name}${WATCH_SEP}${key}`;
858
- return this.inflight.run(dedupeKey, async () => {
1131
+ const dedupeKey = `${q.name}${WATCH_SEP}g${gen}${WATCH_SEP}${key}`;
1132
+ const loadOnce = async () => {
859
1133
  try {
860
1134
  const value = await q.config.loader(...args);
861
1135
  if (q.gen === gen) {
862
- 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) {
863
1138
  q.sizeSkips++;
864
1139
  if (!q.warnedSize) {
865
1140
  q.warnedSize = true;
866
1141
  this.logger.warn?.(
867
- { query: q.name, bytes: estimateSize(value), max: q.maxValueBytes },
1142
+ { query: q.name, bytes, max: q.maxValueBytes },
868
1143
  "hot cache: value exceeds maxValueBytes \u2014 returned but not cached"
869
1144
  );
870
1145
  }
@@ -877,17 +1152,42 @@ var HotCache = class {
877
1152
  q.loadErrors++;
878
1153
  throw err;
879
1154
  }
880
- });
1155
+ };
1156
+ return this.inflight.run(dedupeKey, loadOnce);
881
1157
  }
882
1158
  setEntry(q, key, args, value) {
883
1159
  const now = Date.now();
1160
+ const deps2 = q.hasIdWatch ? this.computeDeps(q, args) : {};
884
1161
  q.lru.set(key, {
885
1162
  value,
886
1163
  args,
887
1164
  expiresAt: q.ttlMs > 0 ? now + q.ttlMs : Infinity,
888
- nextRefreshAt: q.refreshIntervalMs > 0 ? now + q.refreshIntervalMs : Infinity
1165
+ nextRefreshAt: q.refreshIntervalMs > 0 ? now + q.refreshIntervalMs : Infinity,
1166
+ ...deps2
889
1167
  });
890
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
+ }
891
1191
  };
892
1192
  var createHotCache = (options) => new HotCache(options);
893
1193
 
@@ -898,6 +1198,12 @@ var cacheCollectionKey = (dbName, physical) => `${dbName}${NS_SEP}${physical}`;
898
1198
  var QueryCache = class {
899
1199
  lru;
900
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();
901
1207
  /** Per-collection version — bumped on every invalidation/clear of that collection. */
902
1208
  colVersions = /* @__PURE__ */ new Map();
903
1209
  ttlMs;
@@ -909,7 +1215,10 @@ var QueryCache = class {
909
1215
  invalidateEvents = 0;
910
1216
  clearEvents = 0;
911
1217
  constructor(options = {}) {
912
- 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
+ });
913
1222
  this.ttlMs = options.ttlMs ?? 0;
914
1223
  this.clone = options.clone ?? false;
915
1224
  }
@@ -949,14 +1258,14 @@ var QueryCache = class {
949
1258
  return void 0;
950
1259
  }
951
1260
  if (this.ttlMs > 0 && Date.now() > entry.expiresAt) {
952
- this.lru.delete(key);
1261
+ this.dropKey(key);
953
1262
  this.misses++;
954
1263
  return void 0;
955
1264
  }
956
1265
  if (entry.versions) {
957
1266
  for (const [col, ver] of Object.entries(entry.versions)) {
958
1267
  if (this.versionOf(col) !== ver) {
959
- this.lru.delete(key);
1268
+ this.dropKey(key);
960
1269
  this.misses++;
961
1270
  return void 0;
962
1271
  }
@@ -965,6 +1274,23 @@ var QueryCache = class {
965
1274
  this.hits++;
966
1275
  return this.clone ? cloneDeep(entry.value) : entry.value;
967
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
+ }
968
1294
  set(key, value, ttlMs, collections, versions) {
969
1295
  this.sets++;
970
1296
  const ttl = ttlMs ?? this.ttlMs;
@@ -977,6 +1303,7 @@ var QueryCache = class {
977
1303
  const sep = key.indexOf(SEP);
978
1304
  const primary = sep === -1 ? key : key.slice(0, sep);
979
1305
  const collectionKeys = /* @__PURE__ */ new Set([primary, ...collections ?? []]);
1306
+ this.keyCollections.set(key, collectionKeys);
980
1307
  for (const collection of collectionKeys) {
981
1308
  let keys = this.index.get(collection);
982
1309
  if (!keys) {
@@ -988,7 +1315,7 @@ var QueryCache = class {
988
1315
  }
989
1316
  delete(key) {
990
1317
  this.deletes++;
991
- this.lru.delete(key);
1318
+ this.dropKey(key);
992
1319
  }
993
1320
  /** Drop every cached entry belonging to a physical collection. */
994
1321
  invalidateByCollection(collection) {
@@ -996,7 +1323,7 @@ var QueryCache = class {
996
1323
  const keys = this.index.get(collection);
997
1324
  if (!keys) return;
998
1325
  this.invalidateEvents++;
999
- for (const key of keys) this.lru.delete(key);
1326
+ for (const key of [...keys]) this.dropKey(key);
1000
1327
  this.index.delete(collection);
1001
1328
  }
1002
1329
  clear() {
@@ -1006,21 +1333,35 @@ var QueryCache = class {
1006
1333
  }
1007
1334
  this.lru.clear();
1008
1335
  this.index.clear();
1336
+ this.keyCollections.clear();
1009
1337
  }
1010
1338
  };
1011
1339
 
1012
1340
  // src/graceful-transaction.ts
1013
1341
  var withGracefulMongoTransaction = async (runner, fn, options = {}) => {
1014
- const enabled = options.capabilities === void 0 || mongoTransactionsEnabled(options.capabilities, {
1015
- ...options.urlHint !== void 0 ? { urlHint: options.urlHint } : {}
1016
- });
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
+ }
1017
1356
  if (!enabled) return fn(null);
1018
1357
  try {
1019
1358
  return await runner.transaction(fn);
1020
1359
  } catch (err) {
1021
- const message = err instanceof Error ? err.message : "";
1022
- if (/Transaction numbers are only allowed|transactions are not supported/i.test(message)) {
1023
- 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
+ );
1024
1365
  return fn(null);
1025
1366
  }
1026
1367
  if (options.wrapMongoErrors !== false) {
@@ -1119,13 +1460,17 @@ var DataLoader = class {
1119
1460
  }
1120
1461
  }
1121
1462
  async flush() {
1122
- if (this.flushing) return;
1463
+ if (this.flushing) {
1464
+ this.scheduled = false;
1465
+ return;
1466
+ }
1123
1467
  if (this.queue.length === 0) {
1124
1468
  this.scheduled = false;
1125
1469
  return;
1126
1470
  }
1127
1471
  const items = this.queue.splice(0, this.maxBatchSize);
1128
1472
  this.flushing = true;
1473
+ this.scheduled = false;
1129
1474
  try {
1130
1475
  const resultMap = await this.batchFn(items.map((item) => item.key));
1131
1476
  for (const item of items) {
@@ -1140,12 +1485,8 @@ var DataLoader = class {
1140
1485
  }
1141
1486
  } finally {
1142
1487
  this.flushing = false;
1143
- this.scheduled = false;
1144
- if (this.queue.length > 0) {
1145
- this.scheduled = true;
1146
- queueMicrotask(() => {
1147
- void this.flush();
1148
- });
1488
+ if (!this.scheduled && this.queue.length > 0) {
1489
+ this.scheduleFlush();
1149
1490
  }
1150
1491
  }
1151
1492
  }
@@ -1156,6 +1497,7 @@ var canonicalKey = (value) => {
1156
1497
  const anyValue = value;
1157
1498
  if (typeof anyValue.toHexString === "function") return `oid:${anyValue.toHexString()}`;
1158
1499
  if (value instanceof Date) return `date:${value.getTime()}`;
1500
+ return `obj:${stableStringify(value)}`;
1159
1501
  }
1160
1502
  return `${typeof value}:${String(value)}`;
1161
1503
  };
@@ -1166,13 +1508,14 @@ var JOURNAL_COLLECTION = "_migrations";
1166
1508
 
1167
1509
  // src/migrations/files.ts
1168
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);
1169
1512
  var listMigrationFiles = async (dir) => {
1170
1513
  const files = await readdir(dir);
1171
- 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));
1172
1515
  };
1173
1516
  var loadMigration = async (dir, file) => {
1174
1517
  const mod = await import(pathToFileURL(join(dir, file)).href);
1175
- return { name: migrationNameOf(file), ...mod };
1518
+ return { ...mod, name: migrationNameOf(file) };
1176
1519
  };
1177
1520
  var ensureMigrationDir = async (dir) => {
1178
1521
  await mkdir(dir, { recursive: true });
@@ -1186,45 +1529,78 @@ var nextMigrationNumber = async (dir) => {
1186
1529
  };
1187
1530
 
1188
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
+ };
1189
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
+ };
1190
1547
  const appliedNames = async () => {
1191
1548
  const rows = await manager.findMany(JOURNAL_COLLECTION);
1192
1549
  return new Set(
1193
1550
  rows.filter((row) => row.status === void 0 || row.status === "applied").map((row) => row.name)
1194
1551
  );
1195
1552
  };
1196
- const cleanupStale = async () => {
1197
- await manager.deleteMany(JOURNAL_COLLECTION, {
1198
- status: "running",
1199
- appliedAt: { $lt: new Date(Date.now() - 6e4) }
1200
- }).catch(() => {
1201
- });
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
+ }
1202
1586
  };
1203
- const claim = async (name, file) => {
1204
- const claimed = await manager.findOneAndUpdate(
1587
+ const renew = async (name, leaseMs) => {
1588
+ await manager.updateOne(
1205
1589
  JOURNAL_COLLECTION,
1206
- { name },
1207
- {
1208
- $setOnInsert: {
1209
- name,
1210
- appliedAt: /* @__PURE__ */ new Date(),
1211
- status: "running",
1212
- file
1213
- }
1214
- },
1215
- { upsert: true, returnDocument: "before" }
1590
+ { name, status: "running" },
1591
+ { $set: { expiresAt: new Date(Date.now() + leaseMs) } }
1216
1592
  );
1217
- return claimed === null;
1218
1593
  };
1219
1594
  const markApplied = async (name) => {
1220
- await manager.updateOne(JOURNAL_COLLECTION, { name, status: "running" }, { $set: { status: "applied" } }).catch(async () => {
1221
- await manager.insertOne(JOURNAL_COLLECTION, {
1222
- name,
1223
- appliedAt: /* @__PURE__ */ new Date(),
1224
- status: "applied"
1225
- }).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(() => {
1226
1602
  });
1227
- });
1603
+ }
1228
1604
  };
1229
1605
  const removeRunning = async (name) => {
1230
1606
  await manager.deleteOne(JOURNAL_COLLECTION, { name, status: "running" }).catch(() => {
@@ -1233,45 +1609,76 @@ var createMigrationJournal = (manager) => {
1233
1609
  const unrecord = async (name) => {
1234
1610
  await manager.deleteOne(JOURNAL_COLLECTION, { name });
1235
1611
  };
1236
- return { appliedNames, cleanupStale, claim, markApplied, removeRunning, unrecord };
1612
+ return { appliedNames, claim, renew, markApplied, removeRunning, unrecord };
1237
1613
  };
1238
1614
 
1239
1615
  // src/migrations/index.ts
1616
+ var DEFAULT_LEASE_MS = 12e4;
1240
1617
  var createMongoMigrationRunner = (service, options = {}) => {
1241
1618
  const migrationDir = options.migrationDir ?? service.config?.migrationDir ?? "./migrations";
1242
1619
  const logger = options.logger ?? service.logger;
1620
+ const leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;
1243
1621
  const manager = () => {
1244
- 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];
1245
1624
  const db = key ? service.db[key] : void 0;
1246
- 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
+ }
1247
1630
  return db;
1248
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
+ };
1249
1643
  const up = async () => {
1250
1644
  await ensureMigrationDir(migrationDir);
1251
1645
  const journal = createMigrationJournal(manager());
1252
- await journal.cleanupStale();
1253
1646
  const files = await listMigrationFiles(migrationDir);
1254
1647
  const applied = await journal.appliedNames();
1255
1648
  const pending = files.filter((file) => !applied.has(migrationNameOf(file)));
1256
1649
  for (const file of pending) {
1257
1650
  const migration = await loadMigration(migrationDir, file);
1258
1651
  logger.info({ name: migration.name }, "running migration up");
1259
- if (!await journal.claim(migration.name, basename(file))) {
1260
- 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
+ );
1261
1657
  continue;
1262
1658
  }
1659
+ const stopHeartbeat = startHeartbeat(journal, migration.name);
1263
1660
  try {
1264
1661
  await migration.up({ service, logger, name: migration.name });
1265
1662
  await journal.markApplied(migration.name);
1266
1663
  } catch (err) {
1267
1664
  await journal.removeRunning(migration.name);
1268
1665
  throw err;
1666
+ } finally {
1667
+ stopHeartbeat();
1269
1668
  }
1270
1669
  logger.info({ name: migration.name }, "migration applied");
1271
1670
  }
1272
1671
  };
1273
1672
  const down = async (targetName) => {
1274
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
+ }
1275
1682
  const journal = createMigrationJournal(manager());
1276
1683
  const applied = await journal.appliedNames();
1277
1684
  const appliedFiles = files.filter((file) => applied.has(migrationNameOf(file))).reverse();
@@ -1281,7 +1688,7 @@ var createMongoMigrationRunner = (service, options = {}) => {
1281
1688
  await migration.down({ service, logger, name: migration.name });
1282
1689
  await journal.unrecord(migration.name);
1283
1690
  logger.info({ name: migration.name }, "migration rolled back");
1284
- if (targetName && migration.name === targetName) break;
1691
+ if (targetName !== void 0 && migration.name === targetName) break;
1285
1692
  }
1286
1693
  };
1287
1694
  const status = async () => {
@@ -1295,10 +1702,6 @@ var createMongoMigrationRunner = (service, options = {}) => {
1295
1702
  };
1296
1703
  const create = async (name) => {
1297
1704
  await ensureMigrationDir(migrationDir);
1298
- const next = await nextMigrationNumber(migrationDir);
1299
- const padded = String(next).padStart(3, "0");
1300
- const fileName = `${padded}_${name}.ts`;
1301
- const filePath = join(migrationDir, fileName);
1302
1705
  const template = `import type { MigrationContext } from '@ignex/ninox';
1303
1706
 
1304
1707
  export const up = async (ctx: MigrationContext): Promise<void> => {
@@ -1311,15 +1714,26 @@ export const down = async (ctx: MigrationContext): Promise<void> => {
1311
1714
  // await users.client.dropCollection('users');
1312
1715
  };
1313
1716
  `;
1314
- await writeFile(filePath, template, "utf8");
1315
- logger.info({ name, file: fileName }, "migration scaffolded");
1316
- 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
+ }
1317
1731
  };
1318
1732
  return { up, down, status, create };
1319
1733
  };
1320
1734
 
1321
1735
  // src/mongo-helpers.ts
1322
- var withRetry = async (fn, options = {}, _ctx) => {
1736
+ var withRetry = async (fn, options = {}) => {
1323
1737
  const maxAttempts = Math.max(1, options.maxAttempts ?? 3);
1324
1738
  const delayMs = options.delayMs ?? 150;
1325
1739
  const logger = options.logger;
@@ -1332,7 +1746,8 @@ var withRetry = async (fn, options = {}, _ctx) => {
1332
1746
  lastError = err;
1333
1747
  attempt += 1;
1334
1748
  if (attempt >= maxAttempts || !isMongoTransientError(err)) throw err;
1335
- 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));
1336
1751
  logger?.debug?.({ attempt, backoffMs: backoff, error: err?.message }, "retrying");
1337
1752
  await sleep(backoff);
1338
1753
  }
@@ -1362,7 +1777,13 @@ var DEFAULT_FIND_LIMIT = 100;
1362
1777
  var MAX_BATCH_OPS = 1e5;
1363
1778
 
1364
1779
  // src/query-builder/query-builder.ts
1365
- 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 {
1366
1787
  constructor(ctx) {
1367
1788
  this.ctx = ctx;
1368
1789
  }
@@ -1375,77 +1796,112 @@ var QueryBuilder = class {
1375
1796
  get filter() {
1376
1797
  return this._filter;
1377
1798
  }
1378
- /** 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). */
1379
1811
  where(filter) {
1380
- this._filter = { ...this._filter, ...filter };
1381
- 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;
1382
1819
  }
1383
- /** AND-combine the current filter with a new one. */
1820
+ /** AND-combine the current filter with a new one (stays flat across repeats). */
1384
1821
  and(filter) {
1385
- this._filter = { $and: [this._filter, filter] };
1386
- 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;
1387
1827
  }
1388
- /** 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
+ */
1389
1832
  or(...filters) {
1390
- this._filter = { $and: [this._filter, { $or: filters }] };
1391
- 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 });
1392
1837
  }
1393
1838
  /** Set the sort order (`{ field: 1 | -1 }`). */
1394
1839
  sort(sort) {
1395
- this._sort = sort;
1396
- return this;
1840
+ const next = this.fork();
1841
+ next._sort = sort;
1842
+ return next;
1397
1843
  }
1398
1844
  /** Skip the first `n` matching documents. */
1399
1845
  skip(n) {
1400
- this._skip = n;
1401
- return this;
1846
+ const next = this.fork();
1847
+ next._skip = n;
1848
+ return next;
1402
1849
  }
1403
1850
  /** Limit the result set to `n` documents. */
1404
1851
  limit(n) {
1405
- this._limit = n;
1406
- return this;
1852
+ const next = this.fork();
1853
+ next._limit = n;
1854
+ return next;
1407
1855
  }
1408
1856
  /** Project only the given fields — the projection is pushed to the driver. */
1409
1857
  select(fields) {
1858
+ const next = this.fork();
1410
1859
  const projection = {};
1411
1860
  for (const field of fields) projection[field] = 1;
1412
- this._projection = projection;
1413
- return this;
1861
+ next._projection = projection;
1862
+ return next;
1414
1863
  }
1415
1864
  /** Raw projection document (`{ field: 1 }` / `{ field: 0 }`). */
1416
1865
  project(projection) {
1417
- this._projection = projection;
1418
- return this;
1866
+ const next = this.fork();
1867
+ next._projection = projection;
1868
+ return next;
1419
1869
  }
1420
1870
  /** Set the index hint. */
1421
1871
  hint(hint) {
1422
- this._options.hint = hint;
1423
- return this;
1872
+ const next = this.fork();
1873
+ next._options.hint = hint;
1874
+ return next;
1424
1875
  }
1425
1876
  /** Set the driver cursor batch size. */
1426
1877
  batchSize(n) {
1427
- this._options.batchSize = n;
1428
- return this;
1878
+ const next = this.fork();
1879
+ next._options.batchSize = n;
1880
+ return next;
1429
1881
  }
1430
1882
  /** Run the query inside a transaction session. */
1431
1883
  session(session) {
1432
- this._options.session = session;
1433
- return this;
1884
+ const next = this.fork();
1885
+ next._options.session = session;
1886
+ return next;
1434
1887
  }
1435
1888
  /** Cap server-side execution time. */
1436
1889
  maxTimeMS(ms) {
1437
- this._options.maxTimeMS = ms;
1438
- return this;
1890
+ const next = this.fork();
1891
+ next._options.maxTimeMS = ms;
1892
+ return next;
1439
1893
  }
1440
1894
  /** Bypass the read cache for this query when `false`. */
1441
1895
  cache(on) {
1442
- this._options.cache = on;
1443
- return this;
1896
+ const next = this.fork();
1897
+ next._options.cache = on;
1898
+ return next;
1444
1899
  }
1445
1900
  /** Override in-flight dedup for this query when set. */
1446
1901
  dedupe(on) {
1447
- this._options.dedupe = on;
1448
- return this;
1902
+ const next = this.fork();
1903
+ next._options.dedupe = on;
1904
+ return next;
1449
1905
  }
1450
1906
  /** Compile the builder into driver options (does not execute). */
1451
1907
  compile() {
@@ -1487,7 +1943,7 @@ var QueryBuilder = class {
1487
1943
  options
1488
1944
  );
1489
1945
  }
1490
- /** Streaming cursor (raw driver cursor; no retry, applies batchSize). */
1946
+ /** Streaming cursor (raw driver cursor; no retry/cache/drift — caller-owned). */
1491
1947
  cursor() {
1492
1948
  return this.ctx.collection.find(
1493
1949
  this._filter,
@@ -1503,9 +1959,21 @@ var QueryBuilder = class {
1503
1959
  this.countOptions()
1504
1960
  );
1505
1961
  }
1506
- /** True when at least one document matches. */
1962
+ /** True when at least one document matches (`_id`-only projection — no doc transfer). */
1507
1963
  async exists() {
1508
- 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;
1509
1977
  }
1510
1978
  };
1511
1979
 
@@ -1536,8 +2004,9 @@ var createRepository = (manager, collection, options = {}) => {
1536
2004
  findOne: (filter, o) => manager.getOne(collection, filter, o),
1537
2005
  findOneOrFail: (filter, o) => manager.getOneOrFail(collection, filter, o),
1538
2006
  findMany: (filter, o) => manager.findMany(collection, filter, {
1539
- ...o ?? {},
1540
- ...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 ?? {}
1541
2010
  }),
1542
2011
  findActive: (filter, o) => manager.findActive(collection, filter, o),
1543
2012
  count: (filter, o) => manager.countDocuments(collection, filter, o),
@@ -1565,9 +2034,25 @@ var createRepository = (manager, collection, options = {}) => {
1565
2034
 
1566
2035
  // src/schema/collections.ts
1567
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
+ };
1568
2050
  var resolveItemValue = (item) => {
1569
2051
  if ("schema" in item) {
1570
2052
  const def = item;
2053
+ if (def.timestamps !== void 0) {
2054
+ assertTimestampsDeclared(def.name, def.schema, def.timestamps);
2055
+ }
1571
2056
  return {
1572
2057
  schema: def.schema,
1573
2058
  ...def.indexes ? { indexes: def.indexes } : {},
@@ -1579,6 +2064,9 @@ var resolveItemValue = (item) => {
1579
2064
  };
1580
2065
  var defineCollection = (name, schema, extras = {}) => {
1581
2066
  const namedSchema = { ...schema, name };
2067
+ if (extras.timestamps !== void 0) {
2068
+ assertTimestampsDeclared(name, schema, extras.timestamps);
2069
+ }
1582
2070
  return {
1583
2071
  name,
1584
2072
  schema: namedSchema,
@@ -1608,6 +2096,11 @@ var addNumericBounds = (out, field) => {
1608
2096
  if (field.exclusiveMaximum) out.exclusiveMaximum = true;
1609
2097
  return out;
1610
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"];
1611
2104
  var convertField = (field) => {
1612
2105
  if (field.kind === "raw") {
1613
2106
  const out2 = { ...field.fragment };
@@ -1624,13 +2117,15 @@ var convertField = (field) => {
1624
2117
  if (field.pattern) out.pattern = field.pattern;
1625
2118
  return out;
1626
2119
  case "number":
1627
- 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;
1628
2122
  return addNumericBounds(out, field);
1629
2123
  case "double":
1630
- out.bsonType = "double";
2124
+ out.bsonType = [...NUMERIC_WIRE_UNION.double];
1631
2125
  return addNumericBounds(out, field);
1632
2126
  case "long":
1633
- out.bsonType = "long";
2127
+ out.bsonType = [...NUMERIC_WIRE_UNION.long];
2128
+ if (field.multipleOf === void 0) out.multipleOf = 1;
1634
2129
  return addNumericBounds(out, field);
1635
2130
  case "decimal":
1636
2131
  out.bsonType = "decimal";
@@ -1646,6 +2141,7 @@ var convertField = (field) => {
1646
2141
  return out;
1647
2142
  case "geoPoint":
1648
2143
  return {
2144
+ ...field.description ? { description: field.description } : {},
1649
2145
  bsonType: "object",
1650
2146
  required: ["type", "coordinates"],
1651
2147
  properties: {
@@ -1671,7 +2167,10 @@ var convertField = (field) => {
1671
2167
  else if (typeof v === "string") types.add("string");
1672
2168
  else if (typeof v === "number") {
1673
2169
  const allInt = field.values.every((x) => typeof x !== "number" || Number.isInteger(x));
1674
- 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");
1675
2174
  break;
1676
2175
  }
1677
2176
  }
@@ -1825,6 +2324,10 @@ var numericValue = (value) => {
1825
2324
  const n = Number(value.toString());
1826
2325
  return Number.isNaN(n) ? void 0 : n;
1827
2326
  }
2327
+ if (value instanceof Long) {
2328
+ const n = value.toNumber();
2329
+ return Number.isNaN(n) ? void 0 : n;
2330
+ }
1828
2331
  if (typeof value === "number" && !Number.isNaN(value)) return value;
1829
2332
  return void 0;
1830
2333
  };
@@ -1882,7 +2385,7 @@ var bsonTypeMatches = (bsonType, value) => {
1882
2385
  case "double":
1883
2386
  return typeof value === "number" && !Number.isNaN(value);
1884
2387
  case "long":
1885
- return typeof value === "number" && Number.isInteger(value);
2388
+ return typeof value === "number" && Number.isInteger(value) || value instanceof Long;
1886
2389
  case "decimal":
1887
2390
  return value instanceof Decimal128;
1888
2391
  default:
@@ -1904,6 +2407,19 @@ var checkReservedValue = (key, value, path, issues) => {
1904
2407
  };
1905
2408
 
1906
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
+ };
1907
2423
  var stringValidator = (_deps, field, value, path, issues) => {
1908
2424
  const f = field;
1909
2425
  if (typeof value !== "string") {
@@ -1928,19 +2444,15 @@ var stringValidator = (_deps, field, value, path, issues) => {
1928
2444
  `Length ${value.length} > ${f.maxLength}`
1929
2445
  );
1930
2446
  }
1931
- if (f.pattern) {
1932
- try {
1933
- if (!new RegExp(f.pattern).test(value)) {
1934
- push(
1935
- issues,
1936
- path,
1937
- "constraint",
1938
- `string(pattern ${f.pattern})`,
1939
- `"${value}" does not match pattern`
1940
- );
1941
- }
1942
- } catch {
1943
- }
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
+ );
1944
2456
  }
1945
2457
  };
1946
2458
  var numberValidator = (_deps, field, value, path, issues) => {
@@ -1957,7 +2469,7 @@ var numberValidator = (_deps, field, value, path, issues) => {
1957
2469
  };
1958
2470
  var floatingValidator = (_deps, field, value, path, issues) => {
1959
2471
  const f = field;
1960
- 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);
1961
2473
  if (!ok) {
1962
2474
  push(issues, path, "type", f.kind, `Expected ${f.kind}, got ${describeValue(value)}`);
1963
2475
  return;
@@ -2114,7 +2626,7 @@ function validateObjectProps(field, value, path, issues, topLevel) {
2114
2626
  validateValue(prop, value[key], propPath(key), issues);
2115
2627
  }
2116
2628
  if (field.additionalProperties !== true) {
2117
- const allowedReserved = topLevel ? /* @__PURE__ */ new Set(["_id", ...Object.keys(ORM_RESERVED_FIELDS)]) : /* @__PURE__ */ new Set();
2629
+ const allowedReserved = topLevel ? ALLOWED_RESERVED_TOP : NO_RESERVED;
2118
2630
  for (const key of Object.keys(value)) {
2119
2631
  if (key in field.properties) continue;
2120
2632
  if (allowedReserved.has(key)) continue;
@@ -2161,6 +2673,11 @@ function validateObjectProps(field, value, path, issues, topLevel) {
2161
2673
  }
2162
2674
  }
2163
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();
2164
2681
  var validateDoc = (schema, doc) => {
2165
2682
  const issues = [];
2166
2683
  if (doc === null || typeof doc !== "object" || Array.isArray(doc)) {
@@ -2187,15 +2704,23 @@ var createResolveCollectionName = (config) => {
2187
2704
  // src/service/connection-uri.ts
2188
2705
  var normalizeMongoUrl = (rawUrl) => {
2189
2706
  try {
2190
- const url = new URL(rawUrl);
2191
- if (!url.searchParams.has("retryWrites")) url.searchParams.set("retryWrites", "false");
2192
- const hasWriteConcern = url.searchParams.has("w") || url.searchParams.has("wtimeoutMS") || url.searchParams.has("writeConcern");
2193
- if (!hasWriteConcern) url.searchParams.set("w", "1");
2194
- const hostCount = url.hostname.split(",").length;
2195
- if (hostCount === 1 && !url.searchParams.has("replicaSet") && !url.searchParams.has("directConnection")) {
2196
- 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");
2197
2721
  }
2198
- return url.toString();
2722
+ const queryString = params.toString();
2723
+ return queryString ? `${base}?${queryString}` : base;
2199
2724
  } catch {
2200
2725
  return rawUrl;
2201
2726
  }
@@ -2231,7 +2756,12 @@ var CacheInvalidator = class {
2231
2756
  const key = `${ref.db.databaseName}${SEP2}${ref.collection}`;
2232
2757
  if (seen.has(key) || this.streams.has(key)) continue;
2233
2758
  seen.add(key);
2234
- 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
+ });
2235
2765
  }
2236
2766
  }
2237
2767
  /** Close every watcher. Safe to call repeatedly, and before any `start`. */
@@ -2251,11 +2781,17 @@ var CacheInvalidator = class {
2251
2781
  async watchLoop(key, ref) {
2252
2782
  let backoff = 0;
2253
2783
  while (!this.stopped) {
2784
+ let connectedAt = Date.now();
2254
2785
  try {
2255
2786
  const stream = ref.db.collection(ref.collection).watch([]);
2256
- 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
+ );
2257
2792
  });
2258
2793
  this.streams.set(key, stream);
2794
+ connectedAt = Date.now();
2259
2795
  if (backoff > 0) {
2260
2796
  this.cache.invalidateByCollection(
2261
2797
  cacheCollectionKey(ref.db.databaseName, ref.collection)
@@ -2268,6 +2804,7 @@ var CacheInvalidator = class {
2268
2804
  );
2269
2805
  }
2270
2806
  } catch (err) {
2807
+ if (Date.now() - connectedAt > 6e4) backoff = 0;
2271
2808
  const message = err instanceof Error ? err.message : String(err);
2272
2809
  const abandoned = this.streams.get(key);
2273
2810
  this.streams.delete(key);
@@ -2373,6 +2910,7 @@ var makeGetDbUrl = (dbClients, config) => (name) => {
2373
2910
  return normalizeMongoUrl(config.defaultDb);
2374
2911
  };
2375
2912
  var makeConnectionFactory = (dbClients, db, getDbUrl, buildManager2, openedClients = /* @__PURE__ */ new Map()) => {
2913
+ const connecting = /* @__PURE__ */ new Map();
2376
2914
  return async (names) => {
2377
2915
  const keys = names && names.length > 0 ? names : Object.keys(dbClients);
2378
2916
  const openedThisCall = [];
@@ -2383,18 +2921,32 @@ var makeConnectionFactory = (dbClients, db, getDbUrl, buildManager2, openedClien
2383
2921
  const dbUrl = getDbUrl(key);
2384
2922
  let client = openedClients.get(dbUrl);
2385
2923
  if (!client) {
2386
- client = new MongoClient(dbUrl, {
2387
- connectTimeoutMS: definition.connectTimeoutMs ?? 1e4,
2388
- serverSelectionTimeoutMS: definition.connectTimeoutMs ?? 1e4,
2389
- ...definition.readPreference && { readPreference: definition.readPreference },
2390
- ...definition.readConcern && { readConcern: definition.readConcern },
2391
- ...definition.writeConcern && { writeConcern: definition.writeConcern }
2392
- });
2393
- client.on("error", () => {
2394
- });
2395
- openedClients.set(dbUrl, client);
2396
- openedThisCall.push({ url: dbUrl, client });
2397
- 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
+ }
2398
2950
  }
2399
2951
  const dbKey = `${key}Client`;
2400
2952
  db[dbKey] = buildManager2(key, definition.name, client);
@@ -2422,23 +2974,32 @@ var closeConnections = async (openedClients) => {
2422
2974
  };
2423
2975
 
2424
2976
  // src/service/health.ts
2425
- var createHealth = (db) => async () => {
2977
+ var createHealth = (db, options = {}) => async () => {
2426
2978
  const started = performance.now();
2979
+ const timeoutMs = options.timeoutMs ?? 2e3;
2427
2980
  const dbs = await Promise.all(
2428
2981
  Object.entries(db).map(async ([key, manager]) => {
2429
2982
  const name = key.replace(/Client$/, "");
2430
2983
  const handle = manager?.client;
2431
- if (!handle) return { name, ok: false };
2984
+ if (!handle) return { name, ok: false, error: "no client handle on manager" };
2432
2985
  try {
2433
2986
  const t0 = performance.now();
2434
- await withTimeout(handle.admin().command({ ping: 1 }), 2e3, `health:${name}`);
2987
+ await withTimeout(handle.admin().command({ ping: 1 }), timeoutMs, `health:${name}`);
2435
2988
  return { name, ok: true, latencyMs: performance.now() - t0 };
2436
- } catch {
2437
- return { name, ok: false };
2989
+ } catch (err) {
2990
+ return {
2991
+ name,
2992
+ ok: false,
2993
+ error: err instanceof Error ? err.message : String(err)
2994
+ };
2438
2995
  }
2439
2996
  })
2440
2997
  );
2441
- 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
+ };
2442
3003
  };
2443
3004
 
2444
3005
  // src/relation/populate.ts
@@ -2484,57 +3045,72 @@ var distinctKeys = (values) => {
2484
3045
  };
2485
3046
  var makePopulator = (deps2) => {
2486
3047
  const populate = async (docs, relations, options = {}) => {
2487
- for (const relation of relations) {
2488
- const target = relation.foreignField ?? "_id";
2489
- const sourceKeys = distinctKeys(docs.map((doc) => doc[relation.localField]));
2490
- if (relation.type === "belongsTo") {
2491
- const loader = createForeignKeyLoader(deps2, relation.collection, target, options);
2492
- const values = await loader.loadMany(sourceKeys);
2493
- const byKey = new Map(sourceKeys.map((key, i) => [canonicalKey(key), values[i]]));
2494
- for (const doc of docs) {
2495
- const key = doc[relation.localField];
2496
- const match = key === void 0 || key === null ? void 0 : byKey.get(canonicalKey(key))?.[0];
2497
- doc[relation.as] = match ?? (relation.optional ? void 0 : null);
2498
- }
2499
- } else if (relation.type === "hasMany") {
2500
- const loader = createForeignKeyLoader(deps2, relation.collection, target, options);
2501
- const values = await loader.loadMany(sourceKeys);
2502
- const byKey = new Map(sourceKeys.map((key, i) => [canonicalKey(key), values[i] ?? []]));
2503
- for (const doc of docs) {
2504
- const key = doc[relation.localField];
2505
- doc[relation.as] = key === void 0 || key === null ? [] : byKey.get(canonicalKey(key)) ?? [];
2506
- }
2507
- } else {
2508
- const throughLoader = createForeignKeyLoader(
2509
- deps2,
2510
- relation.through.collection,
2511
- relation.through.localField,
2512
- options
2513
- );
2514
- const throughValues = await throughLoader.loadMany(sourceKeys);
2515
- const throughByKey = new Map(
2516
- sourceKeys.map((key, i) => [canonicalKey(key), throughValues[i] ?? []])
2517
- );
2518
- const joinDocs = throughValues.flat();
2519
- const targetKeys = distinctKeys(
2520
- joinDocs.map((join3) => join3[relation.through.foreignField])
2521
- );
2522
- const targetLoader = createForeignKeyLoader(deps2, relation.collection, target, options);
2523
- const targetValues = await targetLoader.loadMany(targetKeys);
2524
- const targetByKey = new Map(
2525
- targetKeys.map((key, i) => [canonicalKey(key), targetValues[i] ?? []])
2526
- );
2527
- for (const doc of docs) {
2528
- const key = doc[relation.localField];
2529
- const joins = key === void 0 || key === null ? [] : throughByKey.get(canonicalKey(key)) ?? [];
2530
- const targets = joins.flatMap(
2531
- (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
2532
3090
  );
2533
- doc[relation.as] = targets;
3091
+ const joinDocs = [...throughByKey.values()].flat();
3092
+ const targetKeys = distinctKeys(
3093
+ joinDocs.map((join3) => join3[relation.through.foreignField])
3094
+ );
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
+ }
2534
3110
  }
2535
- }
2536
- }
2537
- return docs;
3111
+ })
3112
+ );
3113
+ return out;
2538
3114
  };
2539
3115
  return { populate };
2540
3116
  };
@@ -2566,13 +3142,28 @@ var probeDebugQuery = async () => {
2566
3142
  debugQueryCache = null;
2567
3143
  }
2568
3144
  };
3145
+ var sanitizeDebugParams = (value) => {
3146
+ try {
3147
+ return JSON.parse(JSON.stringify(value));
3148
+ } catch {
3149
+ try {
3150
+ return String(value);
3151
+ } catch {
3152
+ return "[unserializable]";
3153
+ }
3154
+ }
3155
+ };
2569
3156
  var traceDbOp = async (logger, meta, fn, options = {}) => {
2570
3157
  const start = performance.now();
2571
3158
  logger.debug({ ...meta, operation: "start" }, "start");
2572
3159
  try {
2573
- if (debugQueryCache === void 0) await probeDebugQuery();
2574
- const debugQuery = debugQueryCache;
2575
- const result = debugQuery ? await debugQuery(`${meta.collection}.${meta.op}`, void 0, () => fn()) : await fn();
3160
+ let debugQuery = options.debugQuery;
3161
+ if (debugQuery === void 0) {
3162
+ if (debugQueryCache === void 0) await probeDebugQuery();
3163
+ debugQuery = debugQueryCache;
3164
+ }
3165
+ const params = meta.params === void 0 ? void 0 : sanitizeDebugParams(meta.params);
3166
+ const result = debugQuery ? await debugQuery(`${meta.collection}.${meta.op}`, params, () => fn()) : await fn();
2576
3167
  const durationMs = performance.now() - start;
2577
3168
  logger.info({ ...meta, durationMs }, "ok");
2578
3169
  return result;
@@ -2588,11 +3179,12 @@ var traceDbOp = async (logger, meta, fn, options = {}) => {
2588
3179
  // src/service/op-deps.ts
2589
3180
  var makeOpDeps = (logger, dbLabel, resolve, wrapMongoErrors) => {
2590
3181
  const trace = (meta2, fn) => traceDbOp(logger, meta2, fn, { wrapMongoErrors });
2591
- const meta = (collection, op) => ({
3182
+ const meta = (collection, op, params) => ({
2592
3183
  collection: String(collection),
2593
3184
  physicalCollection: resolve(String(collection)),
2594
3185
  db: dbLabel,
2595
- op
3186
+ op,
3187
+ ...params !== void 0 ? { params } : {}
2596
3188
  });
2597
3189
  return { trace, meta };
2598
3190
  };
@@ -2638,11 +3230,11 @@ var resolveDriftMode = (perOp, serviceDefault) => {
2638
3230
  };
2639
3231
 
2640
3232
  // src/service/crud-op.ts
2641
- var defineCrudOp = async (deps2, collection, opName, execute, options, write = false) => {
3233
+ var defineCrudOp = async (deps2, collection, opName, execute, options, write = false, params) => {
2642
3234
  const resolved = resolveQueryOptions(options);
2643
3235
  const maxAttempts = write && resolved.retryWrites !== true ? 1 : resolved.maxAttempts;
2644
3236
  return deps2.trace(
2645
- deps2.meta(collection, opName),
3237
+ deps2.meta(collection, opName, params),
2646
3238
  async () => withRetry(async () => execute(resolved), {
2647
3239
  maxAttempts,
2648
3240
  delayMs: resolved.retryDelayMs
@@ -2747,7 +3339,9 @@ var PipelineBuilder = class _PipelineBuilder {
2747
3339
  this.deps.logical,
2748
3340
  "mongo.pipeline",
2749
3341
  run,
2750
- this.deps.options
3342
+ this.deps.options,
3343
+ false,
3344
+ { pipeline: this.stages }
2751
3345
  );
2752
3346
  }
2753
3347
  /** Run the pipeline and return the first output document (or null). */
@@ -2774,7 +3368,9 @@ var PipelineBuilder = class _PipelineBuilder {
2774
3368
  this.deps.logical,
2775
3369
  "mongo.pipeline.first",
2776
3370
  run,
2777
- this.deps.options
3371
+ this.deps.options,
3372
+ false,
3373
+ { pipeline: this.stages }
2778
3374
  );
2779
3375
  }
2780
3376
  /** Streaming cursor (raw driver cursor; no retry / trace). */
@@ -2894,7 +3490,9 @@ var makeAggregateOps = (ctx) => {
2894
3490
  collection,
2895
3491
  "mongo.aggregate",
2896
3492
  (r) => coll(collection).aggregate(pipeline2, mergeAggOptions2(r.driverOpts, r.sdk)),
2897
- options
3493
+ options,
3494
+ false,
3495
+ { pipeline: pipeline2 }
2898
3496
  );
2899
3497
  };
2900
3498
  const pipeline = (collection, options) => new PipelineBuilder({
@@ -2980,7 +3578,7 @@ var createCachedAggregate = (ctx) => {
2980
3578
  const noSession = !resolved.sdk.session;
2981
3579
  const useCache = cache !== void 0 && noSession && resolved.cache !== false && isCacheablePipeline(pipeline);
2982
3580
  const shouldDedupe = resolved.dedupe !== false && (resolved.dedupe === true || opts.dedupeReads === true && noSession) && opts.inFlight !== void 0;
2983
- const runDriver = () => defineCrudOp(deps2, collection, opName, execute, options);
3581
+ const runDriver = () => defineCrudOp(deps2, collection, opName, execute, options, false, { pipeline });
2984
3582
  const key = useCache ? cache.key(
2985
3583
  cacheCollectionKey(client.databaseName, physical),
2986
3584
  stableHash([opName, pipeline, options])
@@ -3010,10 +3608,12 @@ var createCachedAggregate = (ctx) => {
3010
3608
  return runDriver();
3011
3609
  };
3012
3610
  if (shouldDedupe) {
3013
- const dedupeKey = `${cacheCollectionKey(client.databaseName, physical)}|${opName}|${stableHash([pipeline, options])}`;
3014
- return opts.inFlight.run(dedupeKey, runOnce);
3611
+ const primaryKey = cacheCollectionKey(client.databaseName, physical);
3612
+ const gen = cache !== void 0 ? cache.versionOf(primaryKey) : "";
3613
+ const dedupeKey = `${primaryKey}|${gen}|${opName}|${stableHash([pipeline, options])}`;
3614
+ return await opts.inFlight.run(dedupeKey, runOnce);
3015
3615
  }
3016
- return runOnce();
3616
+ return await runOnce();
3017
3617
  };
3018
3618
  };
3019
3619
 
@@ -3235,6 +3835,54 @@ var makeAggregationOps = (client, dbLabel, logger, opts) => {
3235
3835
  };
3236
3836
  };
3237
3837
 
3838
+ // src/schema/apply-defaults.ts
3839
+ var cloneDefault = (value) => {
3840
+ if (value === null || typeof value !== "object") return value;
3841
+ if (typeof structuredClone === "function") return structuredClone(value);
3842
+ return Array.isArray(value) ? [...value] : { ...value };
3843
+ };
3844
+ var applySchemaDefaults = (schema, doc) => {
3845
+ if (!schema || doc === null || typeof doc !== "object" || Array.isArray(doc)) return;
3846
+ for (const [key, prop] of Object.entries(schema.properties)) {
3847
+ if (key in doc && doc[key] !== void 0) {
3848
+ if (prop.kind === "object" && doc[key] !== null && typeof doc[key] === "object" && !Array.isArray(doc[key])) {
3849
+ applySchemaDefaults(prop, doc[key]);
3850
+ }
3851
+ continue;
3852
+ }
3853
+ if (prop.flags.hasDefault) {
3854
+ doc[key] = cloneDefault(prop.flags.defaultValue);
3855
+ }
3856
+ }
3857
+ };
3858
+
3859
+ // src/utils/omit-undefined.ts
3860
+ var stripUndefinedKeys = (doc) => {
3861
+ for (const key of Object.keys(doc)) {
3862
+ if (doc[key] === void 0) {
3863
+ delete doc[key];
3864
+ }
3865
+ }
3866
+ return doc;
3867
+ };
3868
+ var stripUndefinedFromUpdate = (update) => {
3869
+ if (update === null || typeof update !== "object") return update;
3870
+ const doc = update;
3871
+ for (const operator of ["$set", "$setOnInsert"]) {
3872
+ const op = doc[operator];
3873
+ if (op !== null && typeof op === "object" && !Array.isArray(op)) {
3874
+ const opDoc = op;
3875
+ for (const key of Object.keys(opDoc)) {
3876
+ if (opDoc[key] === void 0) delete opDoc[key];
3877
+ }
3878
+ }
3879
+ }
3880
+ for (const key of Object.keys(doc)) {
3881
+ if (doc[key] === void 0) delete doc[key];
3882
+ }
3883
+ return update;
3884
+ };
3885
+
3238
3886
  // src/service/drift.ts
3239
3887
  var checkDocsDrift = (options, collection, op, docs) => {
3240
3888
  if (options.drift === "off") return;
@@ -3279,8 +3927,8 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3279
3927
  );
3280
3928
  const coll = (name) => client.collection(resolve(String(name)));
3281
3929
  const toDriverFilter = (filter) => filter;
3282
- const run = (collection, opName, execute, options) => defineCrudOp(deps2, collection, opName, execute, options);
3283
- const writeRun = (collection, opName, execute, options) => defineCrudOp(deps2, collection, opName, execute, options, true);
3930
+ const run = (collection, opName, execute, options, params) => defineCrudOp(deps2, collection, opName, execute, options, false, params);
3931
+ const writeRun = (collection, opName, execute, options, params) => defineCrudOp(deps2, collection, opName, execute, options, true, params);
3284
3932
  const invalidate = (collection) => {
3285
3933
  opts.cache?.invalidateByCollection(
3286
3934
  cacheCollectionKey(client.databaseName, resolve(String(collection)))
@@ -3288,6 +3936,8 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3288
3936
  };
3289
3937
  const timestampsFor = (collection) => opts.timestamps?.[String(collection)];
3290
3938
  const stampCreate = (collection, doc) => {
3939
+ applySchemaDefaults(opts.getSchema?.(String(collection)), doc);
3940
+ stripUndefinedKeys(doc);
3291
3941
  const ts = timestampsFor(collection);
3292
3942
  if (!ts) return;
3293
3943
  const now = /* @__PURE__ */ new Date();
@@ -3295,6 +3945,7 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3295
3945
  if (ts.updatedAt && doc[ts.updatedAt] === void 0) doc[ts.updatedAt] = now;
3296
3946
  };
3297
3947
  const stampUpdate = (collection, update) => {
3948
+ stripUndefinedFromUpdate(update);
3298
3949
  const formatted = formatUpdateFilter(update);
3299
3950
  const ts = timestampsFor(collection);
3300
3951
  if (ts?.updatedAt) {
@@ -3304,13 +3955,18 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3304
3955
  return formatted;
3305
3956
  };
3306
3957
  const stampReplace = (collection, doc) => {
3958
+ applySchemaDefaults(opts.getSchema?.(String(collection)), doc);
3959
+ stripUndefinedKeys(doc);
3307
3960
  const ts = timestampsFor(collection);
3308
3961
  if (!ts?.updatedAt) return doc;
3309
3962
  return { ...doc, [ts.updatedAt]: /* @__PURE__ */ new Date() };
3310
3963
  };
3311
- const read = async (collection, opName, filter, options, execute, shape = "none") => {
3964
+ const read = async (collection, opName, filter, options, execute, shape = "none", keyExtra) => {
3312
3965
  const physical = resolve(String(collection));
3313
3966
  const resolved = resolveQueryOptions(options);
3967
+ const sentParams = { filter };
3968
+ if (Object.keys(resolved.driverOpts).length > 0) sentParams.options = resolved.driverOpts;
3969
+ if (keyExtra !== void 0) sentParams.field = keyExtra;
3314
3970
  const noSession = !resolved.sdk.session;
3315
3971
  const useCache = opts.cache !== void 0 && noSession && resolved.cache !== false;
3316
3972
  const shouldDedupe = resolved.dedupe !== false && (resolved.dedupe === true || opts.dedupeReads === true && noSession) && opts.inFlight !== void 0;
@@ -3332,16 +3988,28 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3332
3988
  );
3333
3989
  };
3334
3990
  const executeWrapped = () => deps2.trace(
3335
- deps2.meta(collection, opName),
3991
+ deps2.meta(collection, opName, sentParams),
3336
3992
  () => withRetry(() => execute(resolved), {
3337
3993
  maxAttempts: resolved.maxAttempts,
3338
3994
  delayMs: resolved.retryDelayMs
3339
3995
  })
3340
3996
  );
3341
- const key = useCache ? opts.cache.key(
3342
- cacheCollectionKey(client.databaseName, physical),
3343
- stableHash([filter, options])
3344
- ) : void 0;
3997
+ const {
3998
+ maxAttempts: _ma,
3999
+ retryDelayMs: _rd,
4000
+ dedupe: _dd,
4001
+ cache: _ca,
4002
+ retryWrites: _rw,
4003
+ ...resultOptions
4004
+ } = options ?? {};
4005
+ let payloadHash;
4006
+ const getPayloadHash = () => {
4007
+ if (payloadHash === void 0) {
4008
+ payloadHash = stableHash([opName, keyExtra ?? "", filter, resultOptions]);
4009
+ }
4010
+ return payloadHash;
4011
+ };
4012
+ const key = useCache ? opts.cache.key(cacheCollectionKey(client.databaseName, physical), getPayloadHash()) : void 0;
3345
4013
  const fetchAndCheck = () => executeWrapped().then((value) => {
3346
4014
  checkDrift(value);
3347
4015
  return value;
@@ -3360,10 +4028,11 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3360
4028
  return fetchAndCheck();
3361
4029
  };
3362
4030
  if (shouldDedupe) {
3363
- const dedupeKey = `${cacheCollectionKey(client.databaseName, physical)}|${opName}|${stableHash([filter, options])}`;
3364
- return opts.inFlight.run(dedupeKey, runOnce);
4031
+ const colKey = cacheCollectionKey(client.databaseName, physical);
4032
+ const gen = opts.cache !== void 0 ? opts.cache.versionOf(colKey) : "";
4033
+ return await opts.inFlight.run(`${colKey}|${gen}|${getPayloadHash()}`, runOnce);
3365
4034
  }
3366
- return runOnce();
4035
+ return await runOnce();
3367
4036
  };
3368
4037
  const normalizeFindOptions = (options) => {
3369
4038
  if (!options) return {};
@@ -3422,7 +4091,10 @@ var makeReadOps = (ctx) => {
3422
4091
  ...r.sdk.maxTimeMS !== void 0 ? { maxTimeMS: r.sdk.maxTimeMS } : {},
3423
4092
  ...r.sdk.hint !== void 0 ? { hint: r.sdk.hint } : {}
3424
4093
  };
3425
- return coll(collection).findOne(toDriverFilter(f), opts2);
4094
+ return await coll(collection).findOne(
4095
+ toDriverFilter(f),
4096
+ opts2
4097
+ );
3426
4098
  },
3427
4099
  "one"
3428
4100
  );
@@ -3516,7 +4188,7 @@ var makeReadOps = (ctx) => {
3516
4188
  ...r.sdk.maxTimeMS !== void 0 ? { maxTimeMS: r.sdk.maxTimeMS } : {},
3517
4189
  ...r.sdk.hint !== void 0 ? { hint: r.sdk.hint } : {}
3518
4190
  };
3519
- return coll(collection).countDocuments(toDriverFilter(filter ?? {}), opts2);
4191
+ return await coll(collection).countDocuments(toDriverFilter(filter ?? {}), opts2);
3520
4192
  },
3521
4193
  "none"
3522
4194
  );
@@ -3541,21 +4213,24 @@ var makeReadOps = (ctx) => {
3541
4213
  opts2
3542
4214
  );
3543
4215
  },
3544
- "none"
4216
+ "none",
4217
+ String(field)
3545
4218
  );
3546
4219
  };
3547
- const estimatedDocumentCount = async (collection, options) => ctx.run(
4220
+ const estimatedDocumentCount = async (collection, options) => read(
3548
4221
  collection,
3549
4222
  "mongo.estimatedDocumentCount",
4223
+ {},
4224
+ options,
3550
4225
  async (r) => {
3551
4226
  const opts2 = {
3552
4227
  ...r.driverOpts,
3553
4228
  ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {},
3554
4229
  ...r.sdk.maxTimeMS !== void 0 ? { maxTimeMS: r.sdk.maxTimeMS } : {}
3555
4230
  };
3556
- return coll(collection).estimatedDocumentCount(opts2);
4231
+ return await coll(collection).estimatedDocumentCount(opts2);
3557
4232
  },
3558
- options
4233
+ "none"
3559
4234
  );
3560
4235
  return {
3561
4236
  getOne,
@@ -3592,7 +4267,7 @@ var makeWatchOps = (ctx) => {
3592
4267
  ...r.sdk.maxTimeMS !== void 0 ? { maxTimeMS: r.sdk.maxTimeMS } : {},
3593
4268
  ...r.sdk.hint !== void 0 ? { hint: r.sdk.hint } : {}
3594
4269
  };
3595
- return execute(opts);
4270
+ return await execute(opts);
3596
4271
  },
3597
4272
  opName === "mongo.getOne" ? "one" : opName === "mongo.findMany" ? "many" : "none"
3598
4273
  )
@@ -3609,6 +4284,20 @@ var makeWriteOps = (ctx, readOps) => {
3609
4284
  const { opts } = ctx;
3610
4285
  const { coll, toDriverFilter, writeRun, invalidate, stampCreate, stampUpdate, stampReplace } = ctx;
3611
4286
  const { getOne } = readOps;
4287
+ const safeAfterHooks = async (collection, name, hookCtx) => {
4288
+ try {
4289
+ await runHooks(opts.hooks, collection, name, hookCtx);
4290
+ } catch (err) {
4291
+ ctx.logger.error?.(
4292
+ {
4293
+ collection,
4294
+ hook: name,
4295
+ error: err instanceof Error ? err.message : String(err)
4296
+ },
4297
+ `post-commit ${name} hook failed; DB write is already committed`
4298
+ );
4299
+ }
4300
+ };
3612
4301
  const insertOne = async (collection, doc, options) => {
3613
4302
  await runHooks(opts.hooks, String(collection), "beforeCreate", {
3614
4303
  collection: String(collection),
@@ -3623,28 +4312,32 @@ var makeWriteOps = (ctx, readOps) => {
3623
4312
  ...r.driverOpts,
3624
4313
  ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {}
3625
4314
  };
3626
- return coll(collection).insertOne(doc, insertOpts);
4315
+ return await coll(collection).insertOne(
4316
+ doc,
4317
+ insertOpts
4318
+ );
3627
4319
  },
3628
- options
4320
+ options,
4321
+ { doc }
3629
4322
  );
3630
4323
  invalidate(collection);
3631
- await runHooks(opts.hooks, String(collection), "afterCreate", {
4324
+ await safeAfterHooks(String(collection), "afterCreate", {
3632
4325
  collection: String(collection),
3633
4326
  doc
3634
4327
  });
3635
4328
  return result;
3636
4329
  };
3637
4330
  const insertMany = async (collection, docs, options) => {
3638
- await runHooks(opts.hooks, String(collection), "beforeCreate", {
3639
- collection: String(collection),
3640
- docs
3641
- });
3642
- for (const d of docs) stampCreate(collection, d);
3643
4331
  if (docs.length > MAX_BATCH_OPS) {
3644
4332
  throw new BadRequest(
3645
4333
  `insertMany: too many docs (${docs.length} > ${MAX_BATCH_OPS}); split the batch`
3646
4334
  );
3647
4335
  }
4336
+ await runHooks(opts.hooks, String(collection), "beforeCreate", {
4337
+ collection: String(collection),
4338
+ docs
4339
+ });
4340
+ for (const d of docs) stampCreate(collection, d);
3648
4341
  const result = await writeRun(
3649
4342
  collection,
3650
4343
  "mongo.insertMany",
@@ -3658,10 +4351,11 @@ var makeWriteOps = (ctx, readOps) => {
3658
4351
  bulkOpts
3659
4352
  );
3660
4353
  },
3661
- options
4354
+ options,
4355
+ { docs }
3662
4356
  );
3663
4357
  invalidate(collection);
3664
- await runHooks(opts.hooks, String(collection), "afterCreate", {
4358
+ await safeAfterHooks(String(collection), "afterCreate", {
3665
4359
  collection: String(collection),
3666
4360
  docs
3667
4361
  });
@@ -3687,16 +4381,21 @@ var makeWriteOps = (ctx, readOps) => {
3687
4381
  updateOpts
3688
4382
  );
3689
4383
  },
3690
- options
4384
+ options,
4385
+ { filter, update: effective }
3691
4386
  );
3692
4387
  invalidate(collection);
3693
- await runHooks(opts.hooks, String(collection), "afterUpdate", {
4388
+ await safeAfterHooks(String(collection), "afterUpdate", {
3694
4389
  collection: String(collection),
3695
4390
  filter
3696
4391
  });
3697
4392
  return result;
3698
4393
  };
3699
4394
  const updateMany = async (collection, filter, update, options) => {
4395
+ await runHooks(opts.hooks, String(collection), "beforeUpdate", {
4396
+ collection: String(collection),
4397
+ filter
4398
+ });
3700
4399
  const effective = stampUpdate(collection, update);
3701
4400
  const result = await writeRun(
3702
4401
  collection,
@@ -3712,12 +4411,21 @@ var makeWriteOps = (ctx, readOps) => {
3712
4411
  updateOpts
3713
4412
  );
3714
4413
  },
3715
- options
4414
+ options,
4415
+ { filter, update: effective }
3716
4416
  );
3717
4417
  invalidate(collection);
4418
+ await safeAfterHooks(String(collection), "afterUpdate", {
4419
+ collection: String(collection),
4420
+ filter
4421
+ });
3718
4422
  return result;
3719
4423
  };
3720
4424
  const findOneAndUpdate = async (collection, filter, update, options) => {
4425
+ await runHooks(opts.hooks, String(collection), "beforeUpdate", {
4426
+ collection: String(collection),
4427
+ filter
4428
+ });
3721
4429
  const effective = stampUpdate(collection, update);
3722
4430
  const result = await writeRun(
3723
4431
  collection,
@@ -3737,12 +4445,21 @@ var makeWriteOps = (ctx, readOps) => {
3737
4445
  fOpts
3738
4446
  );
3739
4447
  },
3740
- options
4448
+ options,
4449
+ { filter, update: effective }
3741
4450
  );
3742
4451
  invalidate(collection);
4452
+ await safeAfterHooks(String(collection), "afterUpdate", {
4453
+ collection: String(collection),
4454
+ filter
4455
+ });
3743
4456
  return result;
3744
4457
  };
3745
4458
  const findOneAndReplace = async (collection, filter, replacement, options) => {
4459
+ await runHooks(opts.hooks, String(collection), "beforeUpdate", {
4460
+ collection: String(collection),
4461
+ filter
4462
+ });
3746
4463
  const effective = stampReplace(collection, replacement);
3747
4464
  const result = await writeRun(
3748
4465
  collection,
@@ -3762,12 +4479,21 @@ var makeWriteOps = (ctx, readOps) => {
3762
4479
  fOpts
3763
4480
  );
3764
4481
  },
3765
- options
4482
+ options,
4483
+ { filter, replacement: effective }
3766
4484
  );
3767
4485
  invalidate(collection);
4486
+ await safeAfterHooks(String(collection), "afterUpdate", {
4487
+ collection: String(collection),
4488
+ filter
4489
+ });
3768
4490
  return result;
3769
4491
  };
3770
4492
  const replaceOne = async (collection, filter, replacement, options) => {
4493
+ await runHooks(opts.hooks, String(collection), "beforeUpdate", {
4494
+ collection: String(collection),
4495
+ filter
4496
+ });
3771
4497
  const result = await writeRun(
3772
4498
  collection,
3773
4499
  "mongo.replaceOne",
@@ -3782,9 +4508,14 @@ var makeWriteOps = (ctx, readOps) => {
3782
4508
  repOpts
3783
4509
  );
3784
4510
  },
3785
- options
4511
+ options,
4512
+ { filter, replacement }
3786
4513
  );
3787
4514
  invalidate(collection);
4515
+ await safeAfterHooks(String(collection), "afterUpdate", {
4516
+ collection: String(collection),
4517
+ filter
4518
+ });
3788
4519
  return result;
3789
4520
  };
3790
4521
  const deleteOne = async (collection, filter, options) => {
@@ -3800,18 +4531,23 @@ var makeWriteOps = (ctx, readOps) => {
3800
4531
  ...r.driverOpts,
3801
4532
  ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {}
3802
4533
  };
3803
- return coll(collection).deleteOne(toDriverFilter(filter), delOpts);
4534
+ return await coll(collection).deleteOne(toDriverFilter(filter), delOpts);
3804
4535
  },
3805
- options
4536
+ options,
4537
+ { filter }
3806
4538
  );
3807
4539
  invalidate(collection);
3808
- await runHooks(opts.hooks, String(collection), "afterDelete", {
4540
+ await safeAfterHooks(String(collection), "afterDelete", {
3809
4541
  collection: String(collection),
3810
4542
  filter
3811
4543
  });
3812
4544
  return result;
3813
4545
  };
3814
4546
  const deleteMany = async (collection, filter, options) => {
4547
+ await runHooks(opts.hooks, String(collection), "beforeDelete", {
4548
+ collection: String(collection),
4549
+ filter
4550
+ });
3815
4551
  const result = await writeRun(
3816
4552
  collection,
3817
4553
  "mongo.deleteMany",
@@ -3820,14 +4556,23 @@ var makeWriteOps = (ctx, readOps) => {
3820
4556
  ...r.driverOpts,
3821
4557
  ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {}
3822
4558
  };
3823
- return coll(collection).deleteMany(toDriverFilter(filter), delOpts);
4559
+ return await coll(collection).deleteMany(toDriverFilter(filter), delOpts);
3824
4560
  },
3825
- options
4561
+ options,
4562
+ { filter }
3826
4563
  );
3827
4564
  invalidate(collection);
4565
+ await safeAfterHooks(String(collection), "afterDelete", {
4566
+ collection: String(collection),
4567
+ filter
4568
+ });
3828
4569
  return result;
3829
4570
  };
3830
4571
  const findOneAndDelete = async (collection, filter, options) => {
4572
+ await runHooks(opts.hooks, String(collection), "beforeDelete", {
4573
+ collection: String(collection),
4574
+ filter
4575
+ });
3831
4576
  const result = await writeRun(
3832
4577
  collection,
3833
4578
  "mongo.findOneAndDelete",
@@ -3843,13 +4588,25 @@ var makeWriteOps = (ctx, readOps) => {
3843
4588
  fOpts
3844
4589
  );
3845
4590
  },
3846
- options
4591
+ options,
4592
+ { filter }
3847
4593
  );
3848
4594
  invalidate(collection);
4595
+ await safeAfterHooks(String(collection), "afterDelete", {
4596
+ collection: String(collection),
4597
+ filter
4598
+ });
3849
4599
  return result;
3850
4600
  };
3851
4601
  const softDeleteOne = async (collection, filter, options) => {
4602
+ await runHooks(opts.hooks, String(collection), "beforeDelete", {
4603
+ collection: String(collection),
4604
+ filter
4605
+ });
3852
4606
  const active = mergeMongoActiveFilter(true, filter);
4607
+ const effective = stampUpdate(collection, {
4608
+ $set: { deletedAt: /* @__PURE__ */ new Date() }
4609
+ });
3853
4610
  const result = await writeRun(
3854
4611
  collection,
3855
4612
  "mongo.softDeleteOne",
@@ -3860,16 +4617,25 @@ var makeWriteOps = (ctx, readOps) => {
3860
4617
  };
3861
4618
  return coll(collection).updateOne(
3862
4619
  active,
3863
- { $set: { deletedAt: /* @__PURE__ */ new Date() } },
4620
+ effective,
3864
4621
  updateOpts
3865
4622
  );
3866
4623
  },
3867
- options
4624
+ options,
4625
+ { filter, update: effective }
3868
4626
  );
3869
4627
  invalidate(collection);
4628
+ await safeAfterHooks(String(collection), "afterDelete", {
4629
+ collection: String(collection),
4630
+ filter
4631
+ });
3870
4632
  return result;
3871
4633
  };
3872
4634
  const upsert = async (collection, filter, update, options) => {
4635
+ await runHooks(opts.hooks, String(collection), "beforeUpdate", {
4636
+ collection: String(collection),
4637
+ filter
4638
+ });
3873
4639
  const formatted = stampUpdate(collection, update);
3874
4640
  const result = await writeRun(
3875
4641
  collection,
@@ -3880,14 +4646,24 @@ var makeWriteOps = (ctx, readOps) => {
3880
4646
  ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {},
3881
4647
  upsert: true
3882
4648
  };
3883
- return coll(collection).updateOne(toDriverFilter(filter), formatted, updateOpts);
4649
+ return await coll(collection).updateOne(toDriverFilter(filter), formatted, updateOpts);
3884
4650
  },
3885
- options
4651
+ options,
4652
+ { filter, update: formatted }
3886
4653
  );
3887
4654
  invalidate(collection);
4655
+ await safeAfterHooks(String(collection), "afterUpdate", {
4656
+ collection: String(collection),
4657
+ filter
4658
+ });
3888
4659
  return result;
3889
4660
  };
3890
4661
  const bulkUpsert = async (collection, operations, options) => {
4662
+ if (operations.length > MAX_BATCH_OPS) {
4663
+ throw new BadRequest(
4664
+ `bulkUpsert: too many operations (${operations.length} > ${MAX_BATCH_OPS}); split the batch`
4665
+ );
4666
+ }
3891
4667
  const writes = operations.map((op) => ({
3892
4668
  updateOne: {
3893
4669
  filter: op.filter,
@@ -3895,11 +4671,6 @@ var makeWriteOps = (ctx, readOps) => {
3895
4671
  upsert: op.upsert ?? true
3896
4672
  }
3897
4673
  }));
3898
- if (operations.length > MAX_BATCH_OPS) {
3899
- throw new BadRequest(
3900
- `bulkUpsert: too many operations (${operations.length} > ${MAX_BATCH_OPS}); split the batch`
3901
- );
3902
- }
3903
4674
  const result = await writeRun(
3904
4675
  collection,
3905
4676
  "mongo.bulkUpsert",
@@ -3908,9 +4679,10 @@ var makeWriteOps = (ctx, readOps) => {
3908
4679
  ...r.driverOpts,
3909
4680
  ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {}
3910
4681
  };
3911
- return coll(collection).bulkWrite(writes, bulkOpts);
4682
+ return await coll(collection).bulkWrite(writes, bulkOpts);
3912
4683
  },
3913
- options
4684
+ options,
4685
+ { operations }
3914
4686
  );
3915
4687
  invalidate(collection);
3916
4688
  return result;
@@ -3929,9 +4701,10 @@ var makeWriteOps = (ctx, readOps) => {
3929
4701
  ...r.driverOpts,
3930
4702
  ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {}
3931
4703
  };
3932
- return coll(collection).bulkWrite(operations, bulkOpts);
4704
+ return await coll(collection).bulkWrite(operations, bulkOpts);
3933
4705
  },
3934
- options
4706
+ options,
4707
+ { operations }
3935
4708
  );
3936
4709
  invalidate(collection);
3937
4710
  return result;
@@ -4001,14 +4774,29 @@ var tag = (value) => {
4001
4774
  var untag = (tagged) => {
4002
4775
  switch (tagged.t) {
4003
4776
  case "oid":
4777
+ if (typeof tagged.v !== "string") {
4778
+ throw new BadRequest("paginateCursor: invalid cursor \u2014 oid value must be a string");
4779
+ }
4004
4780
  return new ObjectId(tagged.v);
4005
4781
  case "date":
4782
+ if (typeof tagged.v !== "string") {
4783
+ throw new BadRequest("paginateCursor: invalid cursor \u2014 date value must be a string");
4784
+ }
4006
4785
  return new Date(tagged.v);
4007
4786
  case "string":
4787
+ if (typeof tagged.v !== "string") {
4788
+ throw new BadRequest("paginateCursor: invalid cursor \u2014 string value expected");
4789
+ }
4008
4790
  return tagged.v;
4009
4791
  case "number":
4792
+ if (typeof tagged.v !== "number") {
4793
+ throw new BadRequest("paginateCursor: invalid cursor \u2014 number value expected");
4794
+ }
4010
4795
  return tagged.v;
4011
4796
  case "boolean":
4797
+ if (typeof tagged.v !== "boolean") {
4798
+ throw new BadRequest("paginateCursor: invalid cursor \u2014 boolean value expected");
4799
+ }
4012
4800
  return tagged.v;
4013
4801
  case "null":
4014
4802
  return null;
@@ -4094,11 +4882,14 @@ var makePaginateCursor = (ctx) => {
4094
4882
  ...r.driverOpts,
4095
4883
  ...r.sdk.session !== void 0 ? { session: r.sdk.session } : {},
4096
4884
  ...r.sdk.maxTimeMS !== void 0 ? { maxTimeMS: r.sdk.maxTimeMS } : {},
4097
- ...r.sdk.hint !== void 0 ? { hint: r.sdk.hint } : {}
4885
+ ...r.sdk.hint !== void 0 ? { hint: r.sdk.hint } : {},
4886
+ ...r.sdk.batchSize !== void 0 ? { batchSize: r.sdk.batchSize } : {}
4098
4887
  };
4099
4888
  return client.collection(resolve(String(collection))).find(filter, findOpts).sort(effectiveSort).limit(limit + 1).toArray();
4100
4889
  },
4101
- config.queryOptions
4890
+ config.queryOptions,
4891
+ false,
4892
+ { filter, sort: effectiveSort, limit }
4102
4893
  );
4103
4894
  const hasMore = rows.length > limit;
4104
4895
  const page = backwards ? rows.slice(0, limit).reverse() : rows.slice(0, limit);
@@ -4114,10 +4905,16 @@ var makePaginateCursor = (ctx) => {
4114
4905
  page
4115
4906
  );
4116
4907
  const boundary = backwards ? rows[limit] : page[page.length - 1];
4117
- const nextCursor = hasMore && boundary !== void 0 ? encodeCursor({
4118
- sort: config.sort,
4119
- values: sortKeys.map(([f]) => boundary[f])
4120
- }) : null;
4908
+ let nextCursor = null;
4909
+ if (hasMore && boundary !== void 0) {
4910
+ const values = sortKeys.map(([f]) => boundary[f]);
4911
+ if (values.some((v) => v === void 0)) {
4912
+ throw new BadRequest(
4913
+ "paginateCursor: boundary document is missing a sort field \u2014 every sort key must exist on every document for keyset pagination"
4914
+ );
4915
+ }
4916
+ nextCursor = encodeCursor({ sort: config.sort, values });
4917
+ }
4121
4918
  return { data: page, nextCursor, hasMore };
4122
4919
  };
4123
4920
  return { paginateCursor };
@@ -4132,13 +4929,17 @@ var makePaginateFlexible = (ctx) => {
4132
4929
  ...config.page !== void 0 ? { page: config.page } : {},
4133
4930
  ...config.limit !== void 0 ? { limit: config.limit } : {}
4134
4931
  },
4135
- config.maxLimit ?? DEFAULT_MAX_LIMIT
4932
+ config.maxLimit ?? DEFAULT_MAX_LIMIT,
4933
+ // Same default as `findMany`/`paginateCursor` — the two pagination
4934
+ // APIs used to disagree silently (10 vs 100).
4935
+ { limit: DEFAULT_FIND_LIMIT }
4136
4936
  );
4137
4937
  const dataStages = [
4138
4938
  ...config.prePipeline ?? [],
4139
4939
  ...config.sort && Object.keys(config.sort).length > 0 ? [{ $sort: config.sort }] : [],
4140
4940
  { $skip: normalized.offset },
4141
4941
  { $limit: normalized.limit },
4942
+ ...config.projection !== void 0 ? [{ $project: config.projection }] : [],
4142
4943
  ...config.postPipeline ?? []
4143
4944
  ];
4144
4945
  const pipeline = [
@@ -4167,17 +4968,19 @@ var makePaginateFlexible = (ctx) => {
4167
4968
  const row = rows[0];
4168
4969
  const data = row?.data ?? [];
4169
4970
  const totalCount = row?.totalCount?.[0]?.count ?? 0;
4170
- checkDocsDrift(
4171
- {
4172
- logger,
4173
- db: dbLabel,
4174
- drift: resolveDriftMode(config.queryOptions?.drift, opts.drift),
4175
- ...opts.getSchema !== void 0 ? { getSchema: opts.getSchema } : {}
4176
- },
4177
- String(collection),
4178
- "mongo.paginateFlexible",
4179
- data
4180
- );
4971
+ if (config.projection === void 0) {
4972
+ checkDocsDrift(
4973
+ {
4974
+ logger,
4975
+ db: dbLabel,
4976
+ drift: resolveDriftMode(config.queryOptions?.drift, opts.drift),
4977
+ ...opts.getSchema !== void 0 ? { getSchema: opts.getSchema } : {}
4978
+ },
4979
+ String(collection),
4980
+ "mongo.paginateFlexible",
4981
+ data
4982
+ );
4983
+ }
4181
4984
  return buildPaginationResult(data, totalCount, normalized.page, normalized.limit);
4182
4985
  }
4183
4986
  });
@@ -4205,42 +5008,70 @@ var makePaginationOps = (client, dbLabel, logger, opts) => {
4205
5008
  };
4206
5009
 
4207
5010
  // src/service/schema-ops.ts
4208
- var indexKeyOf = (key) => JSON.stringify(
4209
- Object.keys(key).sort().map((k) => [k, key[k]])
4210
- );
5011
+ var optionSignature = (o) => JSON.stringify([
5012
+ Boolean(o?.unique),
5013
+ Boolean(o?.sparse),
5014
+ typeof o?.expireAfterSeconds === "number" ? o.expireAfterSeconds : null
5015
+ ]);
5016
+ var indexKeyOf = (key, options) => `${JSON.stringify(Object.keys(key).map((k) => [k, key[k]]))}|${optionSignature(options)}`;
4211
5017
  var makeSchemaOps = (client, _logger, opts) => {
5018
+ const readCollectionValidator = async (physical) => {
5019
+ const info = await client.listCollections({ name: physical }).next();
5020
+ return info?.options?.validator;
5021
+ };
5022
+ const ensureDeclaredIndexes = async (collection, physical, def) => {
5023
+ const declared = def?.indexes ?? [];
5024
+ if (declared.length === 0) return;
5025
+ const handle = client.collection(physical);
5026
+ const existing = new Set(
5027
+ (await handle.listIndexes().toArray()).filter((i) => i.name !== "_id_").map((i) => indexKeyOf(i.key, i))
5028
+ );
5029
+ for (const index of declared) {
5030
+ if (existing.has(indexKeyOf(index.key, index.options))) continue;
5031
+ try {
5032
+ await handle.createIndex(index.key, index.options);
5033
+ } catch (err) {
5034
+ if (err?.code === 85) {
5035
+ throw new Error(
5036
+ `Cannot apply declared index on "${collection}": an index with the same name/key already exists with different options. Run db.syncIndexes("${collection}") to reconcile index drift.`,
5037
+ { cause: err }
5038
+ );
5039
+ }
5040
+ throw err;
5041
+ }
5042
+ }
5043
+ };
4212
5044
  const createSchema = async (collection, createCollectionOptions) => {
4213
5045
  const physical = opts.resolveCollectionName(collection);
4214
5046
  const def = opts.getDefinition(collection);
4215
- const validator = def?.schema ? toMongoValidator(def.schema) : void 0;
5047
+ const createOptions = {
5048
+ ...createCollectionOptions ?? {},
5049
+ ...def?.schema ? { validator: toMongoValidator(def.schema) } : {}
5050
+ };
5051
+ let created = true;
4216
5052
  try {
4217
- await client.createCollection(physical, {
4218
- ...validator ? { validator } : {},
4219
- ...createCollectionOptions ?? {}
4220
- });
5053
+ await client.createCollection(physical, createOptions);
4221
5054
  } catch (err) {
4222
- if (err?.code === 48) {
4223
- throw new DomainError("COLLECTION_EXISTS", `Collection "${collection}" already exists`, {
4224
- physicalCollection: physical
4225
- });
4226
- }
4227
- throw err;
5055
+ if (err?.code !== 48) throw err;
5056
+ created = false;
4228
5057
  }
4229
- const indexes = def?.indexes ?? [];
4230
- if (indexes.length > 0) {
5058
+ if (created) {
4231
5059
  try {
4232
- await client.collection(physical).createIndexes(
4233
- indexes.map((index) => ({
4234
- key: index.key,
4235
- ...index.options ?? {}
4236
- }))
4237
- );
5060
+ await ensureDeclaredIndexes(collection, physical, def);
4238
5061
  } catch (err) {
4239
5062
  await client.dropCollection(physical).catch(() => {
4240
5063
  });
4241
5064
  throw err;
4242
5065
  }
5066
+ return;
4243
5067
  }
5068
+ if (createOptions.validator) {
5069
+ const current = await readCollectionValidator(physical);
5070
+ if (JSON.stringify(current) !== JSON.stringify(createOptions.validator)) {
5071
+ await client.command({ collMod: physical, validator: createOptions.validator });
5072
+ }
5073
+ }
5074
+ await ensureDeclaredIndexes(collection, physical, def);
4244
5075
  };
4245
5076
  const updateSchema = async (collection, schema) => {
4246
5077
  const physical = opts.resolveCollectionName(collection);
@@ -4261,9 +5092,11 @@ var makeSchemaOps = (client, _logger, opts) => {
4261
5092
  const handle = client.collection(physical);
4262
5093
  const existing = (await handle.listIndexes().toArray()).filter((i) => i.name !== "_id_");
4263
5094
  const existingByKey = new Map(
4264
- existing.map((i) => [indexKeyOf(i.key), i.name])
5095
+ existing.map((i) => [indexKeyOf(i.key, i), i.name])
5096
+ );
5097
+ const declaredByKey = new Map(
5098
+ declared.map((d) => [indexKeyOf(d.key, d.options), d])
4265
5099
  );
4266
- const declaredByKey = new Map(declared.map((d) => [indexKeyOf(d.key), d]));
4267
5100
  const created = [];
4268
5101
  const dropped = [];
4269
5102
  for (const [key] of declaredByKey) {
@@ -4275,7 +5108,9 @@ var makeSchemaOps = (client, _logger, opts) => {
4275
5108
  }
4276
5109
  }
4277
5110
  const after = (await handle.listIndexes().toArray()).filter((i) => i.name !== "_id_");
4278
- const afterByKey = new Map(after.map((i) => [indexKeyOf(i.key), i.name]));
5111
+ const afterByKey = new Map(
5112
+ after.map((i) => [indexKeyOf(i.key, i), i.name])
5113
+ );
4279
5114
  for (const [key, spec] of declaredByKey) {
4280
5115
  if (!existingByKey.has(key)) {
4281
5116
  created.push(spec.options?.name ?? afterByKey.get(key) ?? JSON.stringify(spec.key));
@@ -4294,16 +5129,22 @@ var makeSchemaOps = (client, _logger, opts) => {
4294
5129
 
4295
5130
  // src/service/transaction.ts
4296
5131
  var makeTransactionOps = (client, handle, logger, _config, opts) => {
5132
+ let warnedUnsupported = false;
5133
+ let journalIndexReady = false;
5134
+ const MIGRATE_LEASE_MS = 12e4;
4297
5135
  const transaction = async (fn, options) => {
4298
5136
  const session = client.startSession();
4299
5137
  try {
4300
5138
  return await session.withTransaction(async () => fn(session), options);
4301
5139
  } catch (err) {
4302
- const message = err instanceof Error ? err.message : "";
4303
- const code = err?.code;
4304
- const unsupported = code === 20 || /Transaction numbers are only allowed/i.test(message) || /does not support retryable writes/i.test(message);
4305
- if (unsupported) {
4306
- logger.warn?.({ code }, "transactions unsupported, falling back to non-transactional");
5140
+ if (isTransactionUnsupportedError(err)) {
5141
+ if (!warnedUnsupported) {
5142
+ warnedUnsupported = true;
5143
+ logger.warn?.(
5144
+ { error: err instanceof Error ? err.message : String(err) },
5145
+ "transactions unsupported on this deployment, falling back to non-transactional"
5146
+ );
5147
+ }
4307
5148
  return fn(null);
4308
5149
  }
4309
5150
  if (opts.wrapMongoErrors) {
@@ -4315,34 +5156,105 @@ var makeTransactionOps = (client, handle, logger, _config, opts) => {
4315
5156
  }
4316
5157
  };
4317
5158
  const migrate = async (name, fn, filePath) => {
4318
- const journal = handle.collection(
4319
- opts.migrationCollectionPhysical
4320
- );
5159
+ const journal = handle.collection(opts.migrationCollectionPhysical);
5160
+ if (!journalIndexReady) {
5161
+ try {
5162
+ await journal.createIndex({ name: 1 }, { unique: true });
5163
+ } catch {
5164
+ }
5165
+ journalIndexReady = true;
5166
+ }
5167
+ const isDupKeyError = (err) => {
5168
+ const code = err?.code;
5169
+ if (code === 11e3 || code === 11001) return true;
5170
+ return /E11000 duplicate key/.test(String(err?.message ?? ""));
5171
+ };
4321
5172
  await transaction(async (session) => {
4322
5173
  const sessionOpts = session ? { session } : {};
4323
- const claim = await journal.findOneAndUpdate(
4324
- { name },
5174
+ const now = /* @__PURE__ */ new Date();
5175
+ const expiresAt = new Date(Date.now() + MIGRATE_LEASE_MS);
5176
+ const stole = await journal.findOneAndUpdate(
4325
5177
  {
4326
- $setOnInsert: {
4327
- name,
4328
- appliedAt: /* @__PURE__ */ new Date(),
5178
+ name,
5179
+ status: { $exists: true, $nin: ["applied"] },
5180
+ $or: [
5181
+ { expiresAt: { $lt: now } },
5182
+ {
5183
+ expiresAt: { $exists: false },
5184
+ appliedAt: { $lt: new Date(now.getTime() - MIGRATE_LEASE_MS) }
5185
+ }
5186
+ ]
5187
+ },
5188
+ {
5189
+ $set: {
5190
+ status: "running",
5191
+ appliedAt: now,
5192
+ expiresAt,
4329
5193
  ...filePath ? { file: filePath } : {}
4330
5194
  }
4331
5195
  },
4332
- { upsert: true, returnDocument: "before", ...sessionOpts }
5196
+ { returnDocument: "before", ...sessionOpts }
4333
5197
  );
4334
- if (claim) {
4335
- logger.info({ name }, "migration already applied, skipping");
5198
+ if (stole !== null) {
5199
+ await runClaimed(session);
4336
5200
  return;
4337
5201
  }
5202
+ try {
5203
+ await journal.insertOne(
5204
+ {
5205
+ name,
5206
+ appliedAt: now,
5207
+ status: "running",
5208
+ expiresAt,
5209
+ ...filePath ? { file: filePath } : {}
5210
+ },
5211
+ sessionOpts
5212
+ );
5213
+ } catch (err) {
5214
+ if (!isDupKeyError(err)) throw err;
5215
+ const existing = await journal.findOne(
5216
+ { name },
5217
+ { projection: { status: 1 }, ...sessionOpts }
5218
+ );
5219
+ const status = existing?.status;
5220
+ if (status === void 0 || status === "applied") {
5221
+ logger.info({ name }, "migration already applied, skipping");
5222
+ } else {
5223
+ logger.warn({ name }, "migration claim held by another runner, skipping");
5224
+ }
5225
+ return;
5226
+ }
5227
+ await runClaimed(session);
5228
+ });
5229
+ async function runClaimed(session) {
5230
+ const sessionOpts = session ? { session } : {};
5231
+ const heartbeat = session === null ? setInterval(() => {
5232
+ void journal.updateOne(
5233
+ { name, status: "running" },
5234
+ { $set: { expiresAt: new Date(Date.now() + MIGRATE_LEASE_MS) } }
5235
+ ).catch(() => {
5236
+ });
5237
+ }, MIGRATE_LEASE_MS / 2) : void 0;
5238
+ heartbeat?.unref?.();
4338
5239
  try {
4339
5240
  await fn(session);
5241
+ const res = await journal.updateOne(
5242
+ { name, status: "running" },
5243
+ { $set: { status: "applied" } },
5244
+ sessionOpts
5245
+ );
5246
+ if (res?.matchedCount === 0) {
5247
+ await journal.insertOne({ name, appliedAt: /* @__PURE__ */ new Date(), status: "applied" }, sessionOpts).catch(() => {
5248
+ });
5249
+ }
4340
5250
  } catch (err) {
4341
5251
  await journal.deleteOne({ name }, sessionOpts).catch(() => {
4342
5252
  });
4343
5253
  throw err;
5254
+ } finally {
5255
+ if (heartbeat !== void 0) clearInterval(heartbeat);
4344
5256
  }
4345
- });
5257
+ }
4346
5258
  };
4347
5259
  return { transaction, migrate };
4348
5260
  };
@@ -4487,11 +5399,27 @@ var createMongoService = (dbClients, configOverrides = {}) => {
4487
5399
  };
4488
5400
 
4489
5401
  // src/shared/merge-filters.ts
5402
+ var isOperatorDoc = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
4490
5403
  var mergeMongoFilters = (...parts) => {
4491
5404
  const out = {};
4492
5405
  for (const part of parts) {
4493
5406
  if (!part) continue;
4494
- Object.assign(out, part);
5407
+ for (const [key, value] of Object.entries(part)) {
5408
+ if (!(key in out)) {
5409
+ out[key] = value;
5410
+ continue;
5411
+ }
5412
+ if (key === "$and" || key === "$or") {
5413
+ const prev2 = out[key];
5414
+ out[key] = [
5415
+ ...Array.isArray(prev2) ? prev2 : [prev2],
5416
+ ...Array.isArray(value) ? value : [value]
5417
+ ];
5418
+ continue;
5419
+ }
5420
+ const prev = out[key];
5421
+ out[key] = isOperatorDoc(prev) && isOperatorDoc(value) ? { ...prev, ...value } : value;
5422
+ }
4495
5423
  }
4496
5424
  return out;
4497
5425
  };
@@ -4570,6 +5498,6 @@ var createCachedAsyncFactory = (factory, options = {}) => {
4570
5498
  };
4571
5499
  };
4572
5500
 
4573
- 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 };
5501
+ 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 };
4574
5502
  //# sourceMappingURL=index.js.map
4575
5503
  //# sourceMappingURL=index.js.map