@classytic/mongokit 3.39.0 → 3.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +71 -0
  2. package/dist/{PaginationEngine-D-P19Y9m.d.mts → PaginationEngine-CRHyuUtw.d.mts} +17 -3
  3. package/dist/{PaginationEngine-DQ7beTCR.mjs → PaginationEngine-CfT2Cxkj.mjs} +320 -145
  4. package/dist/{Repository-D7fDpyFx.mjs → Repository-CYhSC9ut.mjs} +844 -794
  5. package/dist/{Repository-IxI983_m.d.mts → Repository-pnLw1rG5.d.mts} +113 -47
  6. package/dist/actions/index.d.mts +1 -1
  7. package/dist/actions/index.mjs +1 -1
  8. package/dist/adapter/index.mjs +1 -1
  9. package/dist/{adapter-BrVfPFSW.mjs → adapter-CkMoMF83.mjs} +1 -1
  10. package/dist/ai/index.d.mts +1 -1
  11. package/dist/better-auth/index.mjs +2 -2
  12. package/dist/{create-repository-CRXjDa3N.d.mts → create-repository-BUx9vfG9.d.mts} +5 -5
  13. package/dist/{create-repository-DOe9E_5F.mjs → create-repository-BYyWzLbt.mjs} +2 -2
  14. package/dist/idempotency/index.d.mts +23 -0
  15. package/dist/idempotency/index.mjs +134 -0
  16. package/dist/{index-CmjNyulu.d.mts → index-SYF8N28D.d.mts} +37 -19
  17. package/dist/index.d.mts +105 -39
  18. package/dist/index.mjs +332 -676
  19. package/dist/indexes/verify-indexes.d.mts +14 -1
  20. package/dist/indexes/verify-indexes.mjs +54 -7
  21. package/dist/join-scope-DSAznE8E.mjs +168 -0
  22. package/dist/{mongooseToJsonSchema-waUVQwK4.mjs → mongooseToJsonSchema-C3k4ide0.mjs} +5 -1
  23. package/dist/{multi-tenant.plugin-B-tzNeYL.d.mts → multi-tenant.plugin-a3UhQARj.d.mts} +19 -3
  24. package/dist/{operations-CrRIoc8Z.d.mts → operations-XiFgG5US.d.mts} +36 -13
  25. package/dist/outbox/index.d.mts +1 -1
  26. package/dist/outbox/index.mjs +6 -4
  27. package/dist/pagination/PaginationEngine.d.mts +2 -2
  28. package/dist/pagination/PaginationEngine.mjs +1 -1
  29. package/dist/pagination/utils/limits.d.mts +1 -1
  30. package/dist/{pagination-BpIv2bdS.d.mts → pagination-D6psses0.d.mts} +116 -13
  31. package/dist/{plugin-options-BuvtOSZf.d.mts → plugin-options-CRhIvSzx.d.mts} +16 -2
  32. package/dist/plugins/index.d.mts +8 -3
  33. package/dist/plugins/index.mjs +98 -25
  34. package/dist/query-defaults-BxUcSOoF.mjs +208 -0
  35. package/dist/read-model/index.d.mts +66 -0
  36. package/dist/read-model/index.mjs +193 -0
  37. package/dist/{repository-ZbLtL7Nk.d.mts → repository-DNHfjAZX.d.mts} +36 -4
  38. package/dist/sync/index.d.mts +53 -0
  39. package/dist/sync/index.mjs +371 -0
  40. package/dist/tenant/index.mjs +2 -0
  41. package/dist/testkit/index.d.mts +40 -3
  42. package/dist/testkit/index.mjs +113 -2
  43. package/dist/{timestamp.plugin-B5eiaItQ.mjs → timestamp.plugin-zfdLYZiO.mjs} +135 -32
  44. package/dist/{to-plain-B8ufk3Qz.d.mts → to-plain-CjgEInWV.d.mts} +2 -2
  45. package/dist/transaction-BrRYtH_b.mjs +655 -0
  46. package/dist/{update-c_01A0if.mjs → update-WgeAUJ_I.mjs} +212 -33
  47. package/dist/utils/index.d.mts +1 -1
  48. package/dist/utils/index.mjs +1 -1
  49. package/dist/{validation-chain.plugin-Bju1D1Sn.mjs → validation-chain.plugin-CHUHWMtk.mjs} +204 -279
  50. package/dist/{validation-chain.plugin-DvrLJIbJ.d.mts → validation-chain.plugin-CTcNJCmV.d.mts} +27 -5
  51. package/package.json +18 -4
@@ -0,0 +1,655 @@
1
+ import { d as setTransactionDeadline } from "./query-defaults-BxUcSOoF.mjs";
2
+ import mongoose from "mongoose";
3
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
4
+ //#region src/capabilities.ts
5
+ /**
6
+ * Read the provenance of a capability descriptor's `transactions` flag.
7
+ * A descriptor without the field is a static declaration (`'declared'`) —
8
+ * which is exactly right for sqlitekit / prismakit / any kit whose answer
9
+ * genuinely does not depend on a live deployment.
10
+ */
11
+ function transactionResolutionOf(caps) {
12
+ const value = caps.transactionsResolution;
13
+ return value === "observed" || value === "unknown" ? value : "declared";
14
+ }
15
+ /**
16
+ * What MongoDB supports through mongokit's surface, DECLARED (not observed).
17
+ *
18
+ * Deployment-dependent flags (`transactions`, `changeStreams` — both need a
19
+ * replica set or mongos) are `true` here because they describe the product,
20
+ * not the server you happen to be pointed at. `nestedTransactions` is NOT one
21
+ * of them: it is `false` unconditionally, because the refusal comes from
22
+ * mongokit's own tx-bound proxy rather than from the deployment. **Do not read this constant to decide whether a live connection can run
23
+ * a transaction** — that is {@link resolveMongoCapabilities}, which is what
24
+ * `Repository#capabilities` returns. This one is for the cross-kit conformance
25
+ * harness (which overrides environment-specific limits where it is
26
+ * constructed) and as the base every observed descriptor is derived from.
27
+ */
28
+ const MONGOKIT_CAPABILITIES = Object.freeze({
29
+ transactionsResolution: "declared",
30
+ transactions: true,
31
+ nestedTransactions: false,
32
+ transactionRetry: "managed",
33
+ upsert: true,
34
+ duplicateKeyError: true,
35
+ optimisticConcurrency: true,
36
+ distinct: true,
37
+ aggregate: true,
38
+ aggregateOps: Object.freeze({
39
+ percentile: true,
40
+ stddev: true,
41
+ topN: true,
42
+ dateBucketTimezone: true,
43
+ customDateBuckets: true,
44
+ dateBucketSubMinute: true,
45
+ cache: true
46
+ }),
47
+ getOrCreate: true,
48
+ countAndExists: true,
49
+ purgeByField: true,
50
+ purgeByFilter: true,
51
+ archiveByFilter: true,
52
+ arrayOperators: true,
53
+ regexFilter: true,
54
+ changeStreams: true,
55
+ lean: true,
56
+ lookupPopulate: true,
57
+ lookupCoerce: true,
58
+ streaming: true,
59
+ getByIdsOrdered: true,
60
+ scopedJoins: true,
61
+ paginationProperties: true,
62
+ bulkUpsert: true,
63
+ readModel: true,
64
+ mergeKeyset: true
65
+ });
66
+ /**
67
+ * Driver `ServerType`s that positively imply a session-capable, transaction-
68
+ * capable deployment. Verified against mongodb driver 7.x: a single-node
69
+ * replica set reports `RSPrimary` (topology `ReplicaSetWithPrimary`, or
70
+ * `Single` when reached with `directConnection`), a standalone `mongod`
71
+ * reports `Standalone`, and a mongos reports `Mongos`.
72
+ *
73
+ * **This is why the check reads SERVER type, not just topology type.** The
74
+ * pre-existing heuristic (`topology.description.type !== 'Single'`) has a
75
+ * false NEGATIVE: a directly-connected single-node replica set is `Single`
76
+ * yet runs transactions fine.
77
+ */
78
+ const TRANSACTION_CAPABLE_SERVER_TYPES = /* @__PURE__ */ new Set([
79
+ "RSPrimary",
80
+ "RSSecondary",
81
+ "RSArbiter",
82
+ "RSOther",
83
+ "RSGhost",
84
+ "Mongos",
85
+ "LoadBalancer"
86
+ ]);
87
+ /** Topology types that positively imply transactions regardless of server detail. */
88
+ const TRANSACTION_CAPABLE_TOPOLOGY_TYPES = /* @__PURE__ */ new Set([
89
+ "ReplicaSetWithPrimary",
90
+ "ReplicaSetNoPrimary",
91
+ "Sharded",
92
+ "LoadBalanced"
93
+ ]);
94
+ function clientOf(connection) {
95
+ const probe = connection;
96
+ if (!probe || typeof probe !== "object") return void 0;
97
+ try {
98
+ return probe.getClient?.() ?? probe.client ?? (probe.topology !== void 0 ? probe : void 0);
99
+ } catch {
100
+ return;
101
+ }
102
+ }
103
+ function serverTypes(description) {
104
+ const servers = description.servers;
105
+ if (!servers) return [];
106
+ return (typeof servers.values === "function" ? [...servers.values()] : Object.values(servers)).map((s) => s?.type).filter((t) => typeof t === "string");
107
+ }
108
+ /**
109
+ * Read whether a connection's LIVE deployment supports multi-document
110
+ * transactions, synchronously, from the driver's SDAM topology description.
111
+ *
112
+ * Free (a few property reads against an object the driver keeps current), so
113
+ * it is deliberately NOT memoized — a connection that opens after the repo was
114
+ * constructed upgrades its own answer with no invalidation step. What IS
115
+ * memoized is the async {@link probeMongoCapabilities} result, which this
116
+ * function consults first so an exotic deployment that answered `hello` but
117
+ * exposes no topology still reports definitively.
118
+ *
119
+ * @returns `'yes'` / `'no'` when the topology is conclusive, `'unknown'`
120
+ * otherwise — never optimistic. See the module docblock on rule 3.
121
+ */
122
+ function resolveTransactionSupport(connection) {
123
+ const cached = readProbeCache(connection);
124
+ if (cached) return cached;
125
+ const description = clientOf(connection)?.topology?.description;
126
+ if (!description) return "unknown";
127
+ const types = serverTypes(description);
128
+ if (types.some((t) => TRANSACTION_CAPABLE_SERVER_TYPES.has(t))) return "yes";
129
+ if (types.length > 0 && types.every((t) => t === "Standalone")) return "no";
130
+ const topologyType = description.type;
131
+ if (topologyType && TRANSACTION_CAPABLE_TOPOLOGY_TYPES.has(topologyType)) return "yes";
132
+ return "unknown";
133
+ }
134
+ function deriveCapabilities(support) {
135
+ const available = support === "yes";
136
+ return Object.freeze({
137
+ ...MONGOKIT_CAPABILITIES,
138
+ transactionsResolution: support === "unknown" ? "unknown" : "observed",
139
+ transactions: available,
140
+ nestedTransactions: false,
141
+ changeStreams: available
142
+ });
143
+ }
144
+ /** Three possible answers ⇒ three frozen singletons; the getter allocates nothing. */
145
+ const DERIVED = Object.freeze({
146
+ yes: deriveCapabilities("yes"),
147
+ no: deriveCapabilities("no"),
148
+ unknown: deriveCapabilities("unknown")
149
+ });
150
+ /**
151
+ * The capability descriptor for a LIVE connection — what
152
+ * `Repository#capabilities` returns.
153
+ *
154
+ * Every deployment-dependent flag reflects the observed topology; everything
155
+ * else comes from {@link MONGOKIT_CAPABILITIES}. Cheap enough to call per
156
+ * access (topology read + one frozen-object lookup).
157
+ */
158
+ function resolveMongoCapabilities(connection) {
159
+ return DERIVED[resolveTransactionSupport(connection)];
160
+ }
161
+ /** The three derived descriptors, for tests and for gates that want to compare identity. */
162
+ function capabilitiesForSupport(support) {
163
+ return DERIVED[support];
164
+ }
165
+ /** Definitive results only. Keyed on the connection object; never holds `'unknown'`. */
166
+ const PROBE_RESULTS = /* @__PURE__ */ new WeakMap();
167
+ /** In-flight de-duplication so N repos on one connection issue ONE `hello`. */
168
+ const PROBE_INFLIGHT = /* @__PURE__ */ new WeakMap();
169
+ function readProbeCache(connection) {
170
+ if (!connection || typeof connection !== "object") return void 0;
171
+ return PROBE_RESULTS.get(connection);
172
+ }
173
+ /** Test seam — drop memoized probe answers (a reused connection object across suites). */
174
+ function resetCapabilityProbeCache(connection) {
175
+ if (connection && typeof connection === "object") {
176
+ PROBE_RESULTS.delete(connection);
177
+ PROBE_INFLIGHT.delete(connection);
178
+ }
179
+ }
180
+ function withTimeout(promise, ms, onTimeout) {
181
+ return new Promise((resolve) => {
182
+ const timer = setTimeout(() => resolve(onTimeout), ms);
183
+ timer.unref?.();
184
+ promise.then((value) => {
185
+ clearTimeout(timer);
186
+ resolve(value);
187
+ }, () => {
188
+ clearTimeout(timer);
189
+ resolve(onTimeout);
190
+ });
191
+ });
192
+ }
193
+ /**
194
+ * Classify a `hello` (or legacy `isMaster`) reply.
195
+ *
196
+ * - `msg: 'isdbgrid'` ⇒ mongos ⇒ transactions (Mongo 4.2+ sharded).
197
+ * - `setName` ⇒ a replica-set member ⇒ transactions.
198
+ * - a reply that IS a hello (`isWritablePrimary` / `ismaster` present) with
199
+ * neither marker ⇒ standalone ⇒ **no**. Verified: a memory-server standalone
200
+ * returns `{ isWritablePrimary: true }` and nothing else.
201
+ * - anything unrecognised ⇒ `'unknown'`. A reply we cannot parse is not a yes.
202
+ */
203
+ function classifyHelloReply(reply) {
204
+ if (!reply || typeof reply !== "object") return "unknown";
205
+ const { msg, setName } = reply;
206
+ if (msg === "isdbgrid") return "yes";
207
+ if (typeof setName === "string" && setName.length > 0) return "yes";
208
+ return "isWritablePrimary" in reply || "ismaster" in reply ? "no" : "unknown";
209
+ }
210
+ async function runProbe(connection, timeoutMs) {
211
+ if (connection.readyState === 2 && typeof connection.asPromise === "function") await withTimeout(connection.asPromise().then(() => void 0, () => void 0), timeoutMs, void 0);
212
+ const fromTopology = resolveTransactionSupport(connection);
213
+ if (fromTopology !== "unknown") return fromTopology;
214
+ const admin = connection.db?.admin?.();
215
+ if (!admin) return "unknown";
216
+ return classifyHelloReply(await withTimeout(admin.command({ hello: 1 }).catch(async () => admin.command({ isMaster: 1 }).catch(() => void 0)), timeoutMs, void 0));
217
+ }
218
+ /**
219
+ * Resolve a connection's real capabilities, waiting for (and if necessary
220
+ * asking) the deployment. Memoized per connection: N repositories and N
221
+ * kernels on one connection issue at most ONE `hello`.
222
+ *
223
+ * Hosts do not have to call this — `Repository#capabilities` reads the live
224
+ * topology synchronously, which is already conclusive once
225
+ * `await mongoose.connect()` has returned (the Kernel Construction Standard's
226
+ * Describe → **Bind** ordering guarantees that). It exists for the cases the
227
+ * sync read cannot cover: binding while a connection is still opening, or a
228
+ * deployment whose driver surfaces no topology description. Calling it makes
229
+ * the SUBSEQUENT synchronous reads definitive, so nothing else needs rewiring:
230
+ *
231
+ * ```ts
232
+ * await probeMongoCapabilities(connection); // once, before bind
233
+ * const engine = defineWallet().bind(connection); // gate now sees the truth
234
+ * ```
235
+ *
236
+ * Only definitive answers are cached — an `'unknown'` may be re-probed later
237
+ * (the deployment may simply not have been up yet).
238
+ */
239
+ async function probeMongoCapabilities(connection, options = {}) {
240
+ if (!connection || typeof connection !== "object") return DERIVED.unknown;
241
+ const key = connection;
242
+ const cached = PROBE_RESULTS.get(key);
243
+ if (cached) return DERIVED[cached];
244
+ const inflight = PROBE_INFLIGHT.get(key);
245
+ if (inflight) return DERIVED[await inflight];
246
+ const run = runProbe(connection, options.timeoutMs ?? 2e3).catch(() => "unknown").then((support) => {
247
+ if (support !== "unknown") PROBE_RESULTS.set(key, support);
248
+ PROBE_INFLIGHT.delete(key);
249
+ return support;
250
+ });
251
+ PROBE_INFLIGHT.set(key, run);
252
+ return DERIVED[await run];
253
+ }
254
+ //#endregion
255
+ //#region src/tx-bound.ts
256
+ /**
257
+ * Map of session-aware method name → positional index of the `{ session }`
258
+ * options bag. Keep this list in sync with the method signatures in
259
+ * `Repository.ts` and the plugin method definitions. Unknown plugin
260
+ * methods pass through unwrapped (callers use the standalone helper).
261
+ */
262
+ const SESSION_OPTIONS_INDEX = Object.freeze({
263
+ create: 1,
264
+ update: 2,
265
+ delete: 1,
266
+ getById: 1,
267
+ getAll: 1,
268
+ getByIds: 1,
269
+ createMany: 1,
270
+ findAll: 1,
271
+ getOrCreate: 2,
272
+ count: 1,
273
+ exists: 1,
274
+ getByQuery: 1,
275
+ getOne: 1,
276
+ findOneAndUpdate: 2,
277
+ distinct: 2,
278
+ aggregate: 1,
279
+ aggregatePaginate: 0,
280
+ aggregatePipeline: 1,
281
+ aggregatePipelinePaginate: 0,
282
+ lookupPopulate: 0,
283
+ cursor: 1,
284
+ iterate: 1,
285
+ bulkUpsert: 1,
286
+ keysetCursor: 1,
287
+ claim: 3,
288
+ claimVersion: 3,
289
+ applyTransition: 3,
290
+ archiveByFilter: 2,
291
+ purgeByField: 3,
292
+ purgeByFilter: 2,
293
+ upsert: 2,
294
+ increment: 3,
295
+ decrement: 3,
296
+ multiplyField: 3,
297
+ setMin: 3,
298
+ setMax: 3,
299
+ pushToArray: 3,
300
+ pullFromArray: 3,
301
+ addToSet: 3,
302
+ setField: 3,
303
+ unsetField: 2,
304
+ renameField: 3,
305
+ atomicUpdate: 2,
306
+ updateMany: 2,
307
+ deleteMany: 1,
308
+ bulkWrite: 1,
309
+ restore: 1,
310
+ getDeleted: 1,
311
+ addSubdocument: 3,
312
+ getSubdocument: 3,
313
+ updateSubdocument: 4,
314
+ deleteSubdocument: 3,
315
+ groupBy: 1,
316
+ sum: 2,
317
+ average: 2,
318
+ min: 2,
319
+ max: 2
320
+ });
321
+ /**
322
+ * Public methods that pass through bound to the outer repo, WITHOUT the session — each for a stated
323
+ * reason. Anything neither here nor in `SESSION_OPTIONS_INDEX` throws when called.
324
+ */
325
+ const PASS_THROUGH = /* @__PURE__ */ new Set([
326
+ "on",
327
+ "off",
328
+ "emit",
329
+ "emitAsync",
330
+ "removeAllListeners",
331
+ "use",
332
+ "useMiddleware",
333
+ "buildAggregation",
334
+ "buildLookup",
335
+ "isDuplicateKeyError",
336
+ "isTransientConflictError",
337
+ "invalidateAggregateCache",
338
+ "registerMethod",
339
+ "hasMethod",
340
+ "getRegisteredMethods",
341
+ "embed",
342
+ "search",
343
+ "lease",
344
+ "extend",
345
+ "release",
346
+ "claimKey",
347
+ "completeClaim",
348
+ "expireClaim",
349
+ "failClaim",
350
+ "releaseClaim",
351
+ "saveClaimProgress",
352
+ "searchSimilar"
353
+ ]);
354
+ /** Methods MongoDB cannot run inside a transaction. */
355
+ const REFUSED_IN_TX = Object.freeze({ watch: "a change stream cannot be opened inside a transaction" });
356
+ /**
357
+ * Build a session-threaded proxy over `outer`. The returned object has the
358
+ * same method signatures, but every CRUD call auto-injects the supplied
359
+ * session into the options bag. Non-CRUD properties (Model, modelName,
360
+ * hook API, utility helpers) pass through.
361
+ */
362
+ function createTxBoundRepo(outer, session) {
363
+ return new Proxy(outer, { get(target, prop, receiver) {
364
+ if (prop === "withTransaction") return () => {
365
+ throw new Error("[mongokit] Nested withTransaction is not supported on a tx-bound repository. Reuse the outer `txRepo` directly, or collapse the nesting.");
366
+ };
367
+ const value = Reflect.get(target, prop, receiver);
368
+ if (typeof value !== "function") return value;
369
+ if (prop === "Model") return value;
370
+ if (typeof prop === "symbol") return value.bind(target);
371
+ if (prop.startsWith("_")) return value.bind(target);
372
+ const optionsIndex = SESSION_OPTIONS_INDEX[prop];
373
+ if (optionsIndex === void 0) {
374
+ if (PASS_THROUGH.has(prop)) return value.bind(target);
375
+ const refused = REFUSED_IN_TX[prop];
376
+ return () => {
377
+ throw new Error(refused ? `[mongokit] ${prop}() cannot run on a tx-bound repository: ${refused}.` : `[mongokit] ${prop}() is not classified for transactions, so on a tx-bound repository it would run OUTSIDE the transaction. Call it on the outer repository with the session the callback receives — \`repo.${prop}(..., { session: uow.session })\` — or add it to tx-bound's SESSION_OPTIONS_INDEX / PASS_THROUGH.`);
378
+ };
379
+ }
380
+ return function txBoundMethod(...args) {
381
+ while (args.length <= optionsIndex) args.push(void 0);
382
+ const current = args[optionsIndex];
383
+ if (current === void 0) args[optionsIndex] = { session };
384
+ else if (typeof current === "object" && current !== null && !Array.isArray(current)) args[optionsIndex] = {
385
+ ...current,
386
+ session
387
+ };
388
+ else throw new TypeError(`[mongokit] ${prop}(): argument ${optionsIndex} must be an options object on a tx-bound repository (got ${Array.isArray(current) ? "an array" : typeof current}) — the session could not be threaded.`);
389
+ return value.apply(target, args);
390
+ };
391
+ } });
392
+ }
393
+ //#endregion
394
+ //#region src/transaction.ts
395
+ /**
396
+ * Cross-repo transaction helper.
397
+ *
398
+ * `Repository#withTransaction` is convenient when every write in a transaction
399
+ * lives on the same repository, but real workflows usually span several:
400
+ *
401
+ * await withTransaction(connection, async (session) => {
402
+ * const txn = await revenue.transaction.create(data, { session });
403
+ * await ledger.entry.create(journal, { session });
404
+ * await revenue.transaction.verify(txn._id, {}, { session });
405
+ * });
406
+ *
407
+ * This module-level helper accepts a Mongoose connection (or anything with a
408
+ * compatible `startSession()`), so hosts don't need to arbitrarily pick one
409
+ * repository to hang the transaction off.
410
+ *
411
+ * The Repository instance method delegates to this function — one source of
412
+ * truth for retry semantics, standalone fallback, and session lifecycle.
413
+ */
414
+ /**
415
+ * Run a callback inside a MongoDB transaction on the given connection.
416
+ *
417
+ * - Starts a session and runs the callback in a transaction with the driver's retry rules
418
+ * (`TransientTransactionError` → retry the attempt, `UnknownTransactionCommitResult` → retry
419
+ * the commit), ending the session in `finally`. See {@link runTransaction}.
420
+ * - When `allowFallback` is true and the deployment doesn't support transactions
421
+ * (e.g. standalone MongoDB in dev), the callback runs once without a
422
+ * transaction on the same session. `onFallback` is invoked with the original
423
+ * error so hosts can log the degradation.
424
+ *
425
+ * @example
426
+ * ```ts
427
+ * import mongoose from 'mongoose';
428
+ * import { withTransaction } from '@classytic/mongokit';
429
+ *
430
+ * await withTransaction(mongoose.connection, async (session) => {
431
+ * const order = await orderRepo.create(data, { session });
432
+ * await inventoryRepo.decrement(order.items, { session });
433
+ * return order;
434
+ * });
435
+ * ```
436
+ */
437
+ async function withTransaction(connection, callback, options = {}) {
438
+ const session = await connection.startSession();
439
+ try {
440
+ return await runTransaction(session, callback, options.transactionOptions);
441
+ } catch (error) {
442
+ const err = error;
443
+ if (options.allowFallback && isTransactionUnsupported(err)) {
444
+ options.onFallback?.(err);
445
+ return await callback(session);
446
+ }
447
+ throw err;
448
+ } finally {
449
+ await session.endSession();
450
+ }
451
+ }
452
+ const MAX_TRANSACTION_TIMEOUT_MS = 12e4;
453
+ const BACKOFF_INITIAL_MS = 5;
454
+ const BACKOFF_MAX_MS = 500;
455
+ const MAX_TIME_MS_EXPIRED = 50;
456
+ function hasLabel(err, label) {
457
+ const e = err;
458
+ if (typeof e?.hasErrorLabel === "function") return e.hasErrorLabel(label);
459
+ return Array.isArray(e?.errorLabels) && e.errorLabels.includes(label);
460
+ }
461
+ function isMaxTimeMSExpired(err) {
462
+ const e = err;
463
+ return e?.code === MAX_TIME_MS_EXPIRED || e?.writeConcernError?.code === MAX_TIME_MS_EXPIRED;
464
+ }
465
+ function timeoutError(cause, csot) {
466
+ if (!csot || cause instanceof mongoose.mongo.MongoOperationTimeoutError) return cause;
467
+ const error = new mongoose.mongo.MongoOperationTimeoutError("Timed out during withTransaction", { cause: cause instanceof Error ? cause : void 0 });
468
+ if (cause instanceof mongoose.mongo.MongoError) for (const label of cause.errorLabels) error.addErrorLabel(label);
469
+ return error;
470
+ }
471
+ /**
472
+ * The driver's convenient-transaction algorithm (start → callback → commit, label-based retry,
473
+ * jittered backoff, a deadline), run here so the driver never attaches a session `timeoutContext`:
474
+ * with one, every legacy bulk write (`insertMany`, `bulkWrite`) in the transaction is refused once
475
+ * the client carries `timeoutMS` (driver <= 7.7: the bulk path re-resolves the inherited client
476
+ * `timeoutMS` as a per-op one). Each operation keeps its own per-op CSOT budget instead.
477
+ * Deadline: `transactionOptions.timeoutMS`, else the client's `timeoutMS`, else 120 s (as the driver).
478
+ * Pinned by `tests/integration/csot-transaction-matrix.test.ts`.
479
+ */
480
+ async function runTransaction(session, callback, transactionOptions = {}) {
481
+ const { timeoutMS: explicitTimeout, ...startOptions } = transactionOptions;
482
+ const clientTimeout = session.timeoutMS;
483
+ const timeoutMS = explicitTimeout ?? clientTimeout;
484
+ const csot = timeoutMS != null;
485
+ const deadline = performance.now() + (timeoutMS ?? MAX_TRANSACTION_TIMEOUT_MS);
486
+ let lastError;
487
+ if (csot) setTransactionDeadline(session, deadline);
488
+ try {
489
+ for (let attempt = 0;; attempt++) {
490
+ if (attempt > 0) {
491
+ const backoff = Math.random() * Math.min(BACKOFF_INITIAL_MS * 1.5 ** (attempt - 1), BACKOFF_MAX_MS);
492
+ if (performance.now() + backoff >= deadline) throw timeoutError(lastError, csot);
493
+ await setTimeout$1(backoff);
494
+ }
495
+ session.startTransaction(startOptions);
496
+ let result;
497
+ try {
498
+ result = await callback(session);
499
+ if (!session.inTransaction()) return result;
500
+ } catch (err) {
501
+ lastError = err;
502
+ await abortKeepingError(session, err);
503
+ if (err instanceof mongoose.mongo.MongoError && hasLabel(err, "TransientTransactionError")) {
504
+ if (performance.now() >= deadline) throw timeoutError(err, csot);
505
+ continue;
506
+ }
507
+ throw err;
508
+ }
509
+ for (;;) {
510
+ const left = deadline - performance.now();
511
+ if (csot && left <= 0) {
512
+ const late = new mongoose.mongo.MongoOperationTimeoutError("Transaction budget exhausted before commit; the transaction was aborted, nothing was committed");
513
+ await abortKeepingError(session, late);
514
+ throw late;
515
+ }
516
+ try {
517
+ await session.commitTransaction(csot ? { timeoutMS: Math.max(1, Math.floor(left)) } : void 0);
518
+ return result;
519
+ } catch (err) {
520
+ lastError = err;
521
+ if (hasLabel(err, "UnknownTransactionCommitResult") && !isMaxTimeMSExpired(err)) {
522
+ if (performance.now() >= deadline) throw timeoutError(err, csot);
523
+ continue;
524
+ }
525
+ if (hasLabel(err, "TransientTransactionError")) break;
526
+ throw err;
527
+ }
528
+ }
529
+ }
530
+ } finally {
531
+ if (csot) setTransactionDeadline(session, void 0);
532
+ }
533
+ }
534
+ /** Abort if still in progress; an abort failure rides along as `abortError`, never replacing `original`. */
535
+ async function abortKeepingError(session, original) {
536
+ if (!session.inTransaction()) return;
537
+ try {
538
+ await session.abortTransaction();
539
+ } catch (abortError) {
540
+ if (original && typeof original === "object") Object.assign(original, { abortError });
541
+ }
542
+ }
543
+ /**
544
+ * Multi-repo transactional batch — every repo in `repos` becomes a
545
+ * session-bound proxy inside the callback. Eliminates the per-call
546
+ * `{ session }` threading that was repeated across ~20 call sites
547
+ * (be-prod outbox writes, order placement, transfer source/dest pairs,
548
+ * payrun saga steps).
549
+ *
550
+ * Same retry + fallback semantics as `withTransaction`: the
551
+ * `transactionOptions` / `allowFallback` / `onFallback` knobs apply.
552
+ *
553
+ * Each property of the input `repos` map is rebound via the
554
+ * `createTxBoundRepo` proxy — every CRUD method on the bound repo
555
+ * auto-injects `session` into its options bag, including
556
+ * `claim` / `claimVersion` / `findOneAndUpdate` and plugin-contributed
557
+ * methods. Non-CRUD properties (Model, modelName, hook engine,
558
+ * idField) pass through to the underlying repo. Nested
559
+ * `boundRepo.withTransaction(...)` throws — reuse the outer bound
560
+ * repos.
561
+ *
562
+ * @example Order placement across three repos in one transaction
563
+ * ```ts
564
+ * import { batchTransaction } from '@classytic/mongokit';
565
+ *
566
+ * const order = await batchTransaction(
567
+ * mongoose.connection,
568
+ * { orders: orderRepo, events: eventRepo, inventory: inventoryRepo },
569
+ * async ({ orders, events, inventory }) => {
570
+ * const created = await orders.create(orderData); // session auto-injected
571
+ * await events.create({ type: 'order.placed', orderId: created._id });
572
+ * await inventory.claim(skuId, { from: 'available', to: 'reserved' });
573
+ * return created;
574
+ * },
575
+ * );
576
+ * ```
577
+ *
578
+ * @example With fallback for standalone-mongo dev environments
579
+ * ```ts
580
+ * await batchTransaction(
581
+ * mongoose.connection,
582
+ * { orders, events },
583
+ * async ({ orders, events }) => { ... },
584
+ * { allowFallback: true, onFallback: (err) => log.warn(err) },
585
+ * );
586
+ * ```
587
+ *
588
+ * @param connection - Mongoose connection (or anything with
589
+ * `startSession()`). Single source of session truth — every bound
590
+ * repo shares this session.
591
+ * @param repos - Map of repo instances to rebind. Keys become the
592
+ * property names on the callback's argument.
593
+ * @param callback - Receives the bound repo map. Return value flows
594
+ * through to the outer `Promise`.
595
+ */
596
+ async function batchTransaction(connection, repos, callback, options = {}) {
597
+ return withTransaction(connection, async (session) => {
598
+ const bound = {};
599
+ for (const key of Object.keys(repos)) {
600
+ const repo = repos[key];
601
+ bound[key] = createTxBoundRepo(repo, session);
602
+ }
603
+ return callback(bound);
604
+ }, options);
605
+ }
606
+ /**
607
+ * Detect whether an error indicates the MongoDB deployment does not support
608
+ * multi-document transactions (standalone server, older topology, etc.).
609
+ *
610
+ * Checks MongoDB error codes first — 263 (standalone) and 20 (unsupported
611
+ * topology) — with a message-matching fallback for edge cases surfaced by
612
+ * driver versions that throw before the proper code lands.
613
+ *
614
+ * **Driver-version drift:** modern mongoose / mongodb-driver versions hit
615
+ * the standalone case via the retryable-writes precondition rather than
616
+ * the transaction precondition (`retryWrites=true` is on by default,
617
+ * standalone Mongo rejects it before the transaction is even attempted).
618
+ * So we accept that message as equivalent — the underlying topology
619
+ * problem is the same, and the fallback semantics are correct either way.
620
+ */
621
+ function isTransactionUnsupported(error) {
622
+ const code = error.code;
623
+ if (code === 263 || code === 20) return true;
624
+ const message = (error.message || "").toLowerCase();
625
+ return message.includes("transaction numbers are only allowed on a replica set member") || message.includes("transaction is not supported") || message.includes("does not support retryable writes");
626
+ }
627
+ /**
628
+ * PROACTIVELY report whether a connection's deployment supports multi-document
629
+ * transactions — the read-side companion to {@link isTransactionUnsupported}
630
+ * (which classifies a FAILED attempt). Lets a caller skip a doomed transaction
631
+ * attempt on standalone dev Mongo rather than starting one just to catch the
632
+ * error.
633
+ *
634
+ * Delegates to {@link resolveTransactionSupport} so mongokit has exactly ONE
635
+ * topology reader. That also fixes a false NEGATIVE this function used to
636
+ * have: it tested `description.type !== 'Single'`, but a single-node replica
637
+ * set reached with `directConnection` is `Single` with a server type of
638
+ * `RSPrimary` and runs transactions fine. The shared reader looks at the
639
+ * SERVER descriptions first.
640
+ *
641
+ * **`unknown` is optimistic HERE and pessimistic in `capabilities.ts` — the
642
+ * difference is deliberate.** This answers "should I bother ATTEMPTING a
643
+ * transaction?", where guessing yes costs one caught error (`allowFallback`
644
+ * still recovers). The capability descriptor answers "may this deployment be
645
+ * trusted with money?", where guessing yes is the bug class this repo keeps
646
+ * hitting (AGENTS.md FAIL LOUD rule 3). Do not "unify" them.
647
+ *
648
+ * Accepts a Mongoose `Connection` (or anything exposing `getClient()`/`client`
649
+ * with a driver `topology`), so it stays decoupled from mongoose types.
650
+ */
651
+ function supportsTransactions(connection) {
652
+ return resolveTransactionSupport(connection) !== "no";
653
+ }
654
+ //#endregion
655
+ export { createTxBoundRepo as a, classifyHelloReply as c, resolveMongoCapabilities as d, resolveTransactionSupport as f, withTransaction as i, probeMongoCapabilities as l, isTransactionUnsupported as n, MONGOKIT_CAPABILITIES as o, transactionResolutionOf as p, supportsTransactions as r, capabilitiesForSupport as s, batchTransaction as t, resetCapabilityProbeCache as u };