@classytic/mongokit 3.40.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 (46) 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-Dvl8jcRY.mjs → PaginationEngine-CfT2Cxkj.mjs} +296 -137
  4. package/dist/{Repository-BMgPSkqZ.mjs → Repository-CYhSC9ut.mjs} +571 -175
  5. package/dist/{Repository-CR8Y9g2E.d.mts → Repository-pnLw1rG5.d.mts} +57 -16
  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-CAle1MSU.d.mts → create-repository-BUx9vfG9.d.mts} +5 -5
  13. package/dist/{create-repository-BzBUMvd1.mjs → create-repository-BYyWzLbt.mjs} +2 -2
  14. package/dist/{index-CmjNyulu.d.mts → index-SYF8N28D.d.mts} +37 -19
  15. package/dist/index.d.mts +55 -15
  16. package/dist/index.mjs +57 -11
  17. package/dist/join-scope-DSAznE8E.mjs +168 -0
  18. package/dist/{mongooseToJsonSchema-waUVQwK4.mjs → mongooseToJsonSchema-C3k4ide0.mjs} +5 -1
  19. package/dist/{multi-tenant.plugin-CBgCo9im.d.mts → multi-tenant.plugin-a3UhQARj.d.mts} +2 -2
  20. package/dist/{operations-CrRIoc8Z.d.mts → operations-XiFgG5US.d.mts} +36 -13
  21. package/dist/outbox/index.d.mts +1 -1
  22. package/dist/outbox/index.mjs +1 -1
  23. package/dist/pagination/PaginationEngine.d.mts +2 -2
  24. package/dist/pagination/PaginationEngine.mjs +1 -1
  25. package/dist/pagination/utils/limits.d.mts +1 -1
  26. package/dist/{pagination-BpIv2bdS.d.mts → pagination-D6psses0.d.mts} +116 -13
  27. package/dist/{plugin-options-Zk46lD6F.d.mts → plugin-options-CRhIvSzx.d.mts} +2 -2
  28. package/dist/plugins/index.d.mts +3 -3
  29. package/dist/plugins/index.mjs +2 -2
  30. package/dist/query-defaults-BxUcSOoF.mjs +208 -0
  31. package/dist/read-model/index.d.mts +66 -0
  32. package/dist/read-model/index.mjs +193 -0
  33. package/dist/{repository-odl03582.d.mts → repository-DNHfjAZX.d.mts} +16 -2
  34. package/dist/sync/index.d.mts +15 -2
  35. package/dist/sync/index.mjs +38 -1
  36. package/dist/testkit/index.d.mts +40 -3
  37. package/dist/testkit/index.mjs +113 -2
  38. package/dist/{timestamp.plugin-DF0gJB6p.mjs → timestamp.plugin-zfdLYZiO.mjs} +50 -31
  39. package/dist/{to-plain-k7SjNlSS.d.mts → to-plain-CjgEInWV.d.mts} +2 -2
  40. package/dist/{transaction-B5L9glO0.mjs → transaction-BrRYtH_b.mjs} +127 -5
  41. package/dist/{update-DG0fCwRJ.mjs → update-WgeAUJ_I.mjs} +44 -22
  42. package/dist/utils/index.d.mts +1 -1
  43. package/dist/utils/index.mjs +1 -1
  44. package/dist/{validation-chain.plugin-BLY2gLEk.mjs → validation-chain.plugin-CHUHWMtk.mjs} +15 -3
  45. package/dist/{validation-chain.plugin-DYp9E7K2.d.mts → validation-chain.plugin-CTcNJCmV.d.mts} +4 -4
  46. package/package.json +8 -3
@@ -1,7 +1,44 @@
1
- import { r as Repository } from "../Repository-CR8Y9g2E.mjs";
2
- import { n as CreateRepositoryConfig } from "../create-repository-CAle1MSU.mjs";
1
+ import { r as Repository } from "../Repository-pnLw1rG5.mjs";
2
+ import { n as CreateRepositoryConfig } from "../create-repository-BUx9vfG9.mjs";
3
3
  import { Connection, Model, Schema } from "mongoose";
4
4
  import { MongoMemoryReplSet, MongoMemoryServer } from "mongodb-memory-server";
5
+ //#region src/testkit/query-plan.d.ts
6
+ interface RecordedCommand {
7
+ name: string;
8
+ collection: string | undefined;
9
+ command: Record<string, unknown>;
10
+ }
11
+ /** Run `fn` and return every command it started on `connection` (in start order). */
12
+ declare function recordCommands<T>(connection: Connection, fn: () => Promise<T>): Promise<{
13
+ result: T;
14
+ commands: RecordedCommand[];
15
+ }>;
16
+ interface QueryPlanOptions {
17
+ connection: Connection;
18
+ /** Plan stages that fail the gate. Default `['COLLSCAN', 'SORT']` (a scan, an unindexed sort). */
19
+ forbid?: readonly string[];
20
+ /** The examined collection must hold at least this many documents. */
21
+ minDocs: number;
22
+ /** Every index scan must lead with these keys (directly, or after one equality scope field). */
23
+ leadingKeys?: readonly string[];
24
+ /** Only gate commands on these collections (default: every plannable command). */
25
+ collections?: readonly string[];
26
+ }
27
+ interface PlannedCommand {
28
+ name: string;
29
+ collection: string;
30
+ stages: string[];
31
+ indexes: string[][];
32
+ }
33
+ /**
34
+ * Run `run`, explain every read it issued and throw on a forbidden stage, a wrong leading index,
35
+ * an undersized fixture, or no plannable command at all. Returns what each command used.
36
+ */
37
+ declare function assertQueryPlan<T>(run: () => Promise<T>, options: QueryPlanOptions): Promise<{
38
+ result: T;
39
+ plans: PlannedCommand[];
40
+ }>;
41
+ //#endregion
5
42
  //#region src/testkit/index.d.ts
6
43
  interface MongoMemoryOptions {
7
44
  /**
@@ -132,4 +169,4 @@ interface MongoMemoryBackend {
132
169
  */
133
170
  declare function mongoMemoryBackend(options?: MongoMemoryOptions): MongoMemoryBackend;
134
171
  //#endregion
135
- export { CreateTestRepositoryOptions, MongoMemoryBackend, MongoMemoryHandle, MongoMemoryOptions, MongoTestContext, TestConnection, TestRepository, clearCollections, createMongoMemory, createTestConnection, createTestRepository, mongoMemoryBackend, withMongoMemory };
172
+ export { CreateTestRepositoryOptions, MongoMemoryBackend, MongoMemoryHandle, MongoMemoryOptions, MongoTestContext, type PlannedCommand, type QueryPlanOptions, type RecordedCommand, TestConnection, TestRepository, assertQueryPlan, clearCollections, createMongoMemory, createTestConnection, createTestRepository, mongoMemoryBackend, recordCommands, withMongoMemory };
@@ -1,5 +1,116 @@
1
- import { t as createRepository } from "../create-repository-BzBUMvd1.mjs";
1
+ import { t as createRepository } from "../create-repository-BYyWzLbt.mjs";
2
2
  import { createConnection } from "mongoose";
3
+ //#region src/testkit/query-plan.ts
4
+ const PLANNABLE = /* @__PURE__ */ new Set([
5
+ "find",
6
+ "aggregate",
7
+ "count",
8
+ "distinct"
9
+ ]);
10
+ /** Session/transport fields the server refuses inside an `explain`. */
11
+ const STRIP = /* @__PURE__ */ new Set([
12
+ "lsid",
13
+ "txnNumber",
14
+ "autocommit",
15
+ "startTransaction",
16
+ "readConcern",
17
+ "maxTimeMS"
18
+ ]);
19
+ function assertMonitored(connection) {
20
+ if (connection.getClient().options.monitorCommands !== true) throw new Error("[mongokit/testkit] the connection was not opened with { monitorCommands: true }: no command can be observed");
21
+ }
22
+ /** Run `fn` and return every command it started on `connection` (in start order). */
23
+ async function recordCommands(connection, fn) {
24
+ assertMonitored(connection);
25
+ const client = connection.getClient();
26
+ const commands = [];
27
+ const listener = (event) => {
28
+ const target = event.command[event.commandName];
29
+ commands.push({
30
+ name: event.commandName,
31
+ collection: typeof target === "string" ? target : void 0,
32
+ command: event.command
33
+ });
34
+ };
35
+ client.on("commandStarted", listener);
36
+ try {
37
+ return {
38
+ result: await fn(),
39
+ commands
40
+ };
41
+ } finally {
42
+ client.off("commandStarted", listener);
43
+ }
44
+ }
45
+ function collectPlanNodes(node, out) {
46
+ if (Array.isArray(node)) {
47
+ for (const n of node) collectPlanNodes(n, out);
48
+ return;
49
+ }
50
+ if (!node || typeof node !== "object") return;
51
+ const obj = node;
52
+ if (typeof obj.stage === "string") out.push(obj);
53
+ for (const [key, value] of Object.entries(obj)) {
54
+ if (key === "rejectedPlans" || key === "allPlansExecution") continue;
55
+ collectPlanNodes(value, out);
56
+ }
57
+ }
58
+ function explainable(command) {
59
+ return Object.fromEntries(Object.entries(command).filter(([k]) => !k.startsWith("$") && !STRIP.has(k)));
60
+ }
61
+ /**
62
+ * Run `run`, explain every read it issued and throw on a forbidden stage, a wrong leading index,
63
+ * an undersized fixture, or no plannable command at all. Returns what each command used.
64
+ */
65
+ async function assertQueryPlan(run, options) {
66
+ const forbid = options.forbid ?? ["COLLSCAN", "SORT"];
67
+ const { result, commands } = await recordCommands(options.connection, run);
68
+ const db = options.connection.db;
69
+ if (!db) throw new Error("[mongokit/testkit] connection has no db handle");
70
+ const plannable = commands.filter((c) => PLANNABLE.has(c.name) && c.collection !== void 0 && (!options.collections || options.collections.includes(c.collection)));
71
+ if (plannable.length === 0) throw new Error(`[mongokit/testkit] assertQueryPlan saw no plannable command (saw: ${commands.map((c) => c.name).join(", ") || "none"})`);
72
+ const plans = [];
73
+ const failures = [];
74
+ for (const c of plannable) {
75
+ const collection = c.collection;
76
+ const size = await db.collection(collection).countDocuments({});
77
+ if (size < options.minDocs) throw new Error(`[mongokit/testkit] '${collection}' holds ${size} documents, below minDocs ${options.minDocs}: the plan would not be representative`);
78
+ const explained = await db.command({
79
+ explain: explainable(c.command),
80
+ verbosity: "executionStats"
81
+ });
82
+ const nodes = [];
83
+ collectPlanNodes(explained.queryPlanner ?? explained.stages ?? explained, nodes);
84
+ const stages = nodes.map((n) => n.stage);
85
+ const indexes = nodes.filter((n) => n.keyPattern && typeof n.keyPattern === "object").map((n) => Object.keys(n.keyPattern));
86
+ plans.push({
87
+ name: c.name,
88
+ collection,
89
+ stages,
90
+ indexes
91
+ });
92
+ for (const stage of forbid) if (stages.includes(stage)) failures.push(`${c.name} on '${collection}': plan contains ${stage}`);
93
+ if (options.leadingKeys) {
94
+ const want = options.leadingKeys;
95
+ const leads = (key, at) => want.every((k, i) => key[at + i] === k);
96
+ if (indexes.length === 0) failures.push(`${c.name} on '${collection}': no index used, expected (${want.join(",")})`);
97
+ for (const key of indexes) if (!leads(key, 0) && !leads(key, 1)) failures.push(`${c.name} on '${collection}': index (${key.join(",")}) does not lead with (${want.join(",")})`);
98
+ }
99
+ }
100
+ if (failures.length > 0) {
101
+ const error = /* @__PURE__ */ new Error(`[mongokit/testkit] query plan gate failed:\n ${failures.join("\n ")}`);
102
+ Object.assign(error, {
103
+ code: "mongokit.testkit.query_plan",
104
+ plans
105
+ });
106
+ throw error;
107
+ }
108
+ return {
109
+ result,
110
+ plans
111
+ };
112
+ }
113
+ //#endregion
3
114
  //#region src/testkit/index.ts
4
115
  function idempotentStop(server) {
5
116
  let stopped = false;
@@ -136,4 +247,4 @@ function mongoMemoryBackend(options = {}) {
136
247
  } };
137
248
  }
138
249
  //#endregion
139
- export { clearCollections, createMongoMemory, createTestConnection, createTestRepository, mongoMemoryBackend, withMongoMemory };
250
+ export { assertQueryPlan, clearCollections, createMongoMemory, createTestConnection, createTestRepository, mongoMemoryBackend, recordCommands, withMongoMemory };
@@ -1,5 +1,6 @@
1
- import { i as ALL_OPERATIONS, o as OP_REGISTRY, t as HOOK_PRIORITY } from "./Repository-BMgPSkqZ.mjs";
1
+ import { i as ALL_OPERATIONS, o as OP_REGISTRY, t as HOOK_PRIORITY } from "./Repository-CYhSC9ut.mjs";
2
2
  import { n as warn } from "./logger-K7dPmu2U.mjs";
3
+ import { n as declareCollectionScope } from "./join-scope-DSAznE8E.mjs";
3
4
  import { throwIfAborted, withRetry } from "@classytic/repo-core/repository";
4
5
  import mongoose from "mongoose";
5
6
  import { cachePlugin } from "@classytic/repo-core/cache";
@@ -1067,6 +1068,41 @@ function multiTenantPlugin(options = {}) {
1067
1068
  name: "multi-tenant",
1068
1069
  apply(repo) {
1069
1070
  declareTenantContextKey(repo, contextKey);
1071
+ /** The one tenant decision, shared by the hook and the join rule below. */
1072
+ const decide = (context, op) => {
1073
+ if (context.bypassTenant === true) return { bypass: "option" };
1074
+ if (skipWhen?.(context, op) === true) return { bypass: "callback" };
1075
+ let tenantId = context[contextKey];
1076
+ if (!tenantId && resolveContext) tenantId = resolveContext();
1077
+ return { tenantId };
1078
+ };
1079
+ const castTenant = (tenantId) => fieldType === "objectId" ? new mongoose.Types.ObjectId(tenantId) : tenantId;
1080
+ const missingTenant = (op) => new Error([
1081
+ `[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'.`,
1082
+ "",
1083
+ "Three ways to provide it — pick the one that fits the call site:",
1084
+ "",
1085
+ ` 1. Per-call: repo.${op}(..., { ${contextKey}: '<id>' })`,
1086
+ " 2. Ambient (ALS): `createTenantContext()` + `resolveContext` —",
1087
+ " wrap the request in `tenantContext.run({ tenantId }, fn)`,",
1088
+ " then every repo call inside the closure inherits the",
1089
+ " tenant with no options-bag plumbing. See",
1090
+ " `createTenantContext` in @classytic/mongokit.",
1091
+ " 3. Cross-tenant: `bypassTenant: true` per-call (admin / migration /",
1092
+ " support), or `skipWhen: adminBypass({...})` at plugin",
1093
+ " construction for an always-on role-based bypass.",
1094
+ "",
1095
+ "Or set `required: false` if this collection is genuinely not tenant-scoped."
1096
+ ].join("\n"));
1097
+ if (repo.Model) declareCollectionScope(repo.Model, (context, op) => {
1098
+ const d = decide(context, op);
1099
+ if ("bypass" in d) return void 0;
1100
+ if (!d.tenantId) {
1101
+ if (required) throw missingTenant(op);
1102
+ return;
1103
+ }
1104
+ return { [tenantField]: castTenant(d.tenantId) };
1105
+ }, { tenantField });
1070
1106
  const allOps = [...ALL_OPERATIONS.map((op) => ({
1071
1107
  op,
1072
1108
  policyKey: OP_REGISTRY[op].policyKey
@@ -1075,41 +1111,21 @@ function multiTenantPlugin(options = {}) {
1075
1111
  if (skipOperations.includes(op)) continue;
1076
1112
  if (policyKey === "none") continue;
1077
1113
  repo.on(`before:${op}`, (context) => {
1078
- const perCallBypass = context.bypassTenant === true;
1079
- const callbackBypass = !perCallBypass && skipWhen?.(context, op) === true;
1080
- if (perCallBypass || callbackBypass) {
1114
+ const decision = decide(context, op);
1115
+ if ("bypass" in decision) {
1081
1116
  repo.emit("after:tenant-bypass", {
1082
1117
  context,
1083
1118
  operation: op,
1084
- reason: perCallBypass ? "option" : "callback"
1119
+ reason: decision.bypass
1085
1120
  });
1086
1121
  return;
1087
1122
  }
1088
- let tenantId = context[contextKey];
1089
- if (!tenantId && resolveContext) {
1090
- tenantId = resolveContext();
1091
- if (tenantId) context[contextKey] = tenantId;
1092
- }
1123
+ const tenantId = decision.tenantId;
1124
+ if (tenantId && !context[contextKey]) context[contextKey] = tenantId;
1093
1125
  if (!tenantId && allowDataInjection && payloadHasTenantField(context, policyKey, tenantField)) return;
1094
- if (!tenantId && required) throw new Error([
1095
- `[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'.`,
1096
- "",
1097
- "Three ways to provide it — pick the one that fits the call site:",
1098
- "",
1099
- ` 1. Per-call: repo.${op}(..., { ${contextKey}: '<id>' })`,
1100
- " 2. Ambient (ALS): `createTenantContext()` + `resolveContext` —",
1101
- " wrap the request in `tenantContext.run({ tenantId }, fn)`,",
1102
- " then every repo call inside the closure inherits the",
1103
- " tenant with no options-bag plumbing. See",
1104
- " `createTenantContext` in @classytic/mongokit.",
1105
- " 3. Cross-tenant: `bypassTenant: true` per-call (admin / migration /",
1106
- " support), or `skipWhen: adminBypass({...})` at plugin",
1107
- " construction for an always-on role-based bypass.",
1108
- "",
1109
- "Or set `required: false` if this collection is genuinely not tenant-scoped."
1110
- ].join("\n"));
1126
+ if (!tenantId && required) throw missingTenant(op);
1111
1127
  if (!tenantId) return;
1112
- const castId = fieldType === "objectId" ? new mongoose.Types.ObjectId(tenantId) : tenantId;
1128
+ const castId = castTenant(tenantId);
1113
1129
  const assertTenantValue = (supplied, where) => {
1114
1130
  if (onMismatch === "overwrite") return;
1115
1131
  if (supplied === void 0 || supplied === null) return;
@@ -1318,6 +1334,7 @@ function softDeletePlugin(options = {}) {
1318
1334
  return {
1319
1335
  name: "softDelete",
1320
1336
  apply(repo) {
1337
+ if (repo.Model) declareCollectionScope(repo.Model, () => buildDeletedFilter(deletedField, filterMode, false));
1321
1338
  try {
1322
1339
  const schemaPaths = repo.Model.schema.paths;
1323
1340
  if (!schemaPaths[deletedField]) warn(`[softDeletePlugin] Model '${repo.Model.modelName}' has no '${deletedField}' schema path. Soft-delete marks documents by \`$set\`-ing '${deletedField}', but Mongoose strict mode drops updates to undeclared paths — so delete() will SILENTLY no-op (documents are never soft-deleted). Declare it on the schema: { ${deletedField}: { type: Date, default: ${filterMode === "null" ? "null" : "undefined"} } }`);
@@ -1378,7 +1395,8 @@ function softDeletePlugin(options = {}) {
1378
1395
  const SOFT_DELETE_SPECIAL = /* @__PURE__ */ new Set([
1379
1396
  "updateMany",
1380
1397
  "deleteMany",
1381
- "bulkWrite"
1398
+ "bulkWrite",
1399
+ "bulkUpsert"
1382
1400
  ]);
1383
1401
  for (const op of ALL_OPERATIONS) {
1384
1402
  if (SOFT_DELETE_SPECIAL.has(op)) continue;
@@ -1409,7 +1427,7 @@ function softDeletePlugin(options = {}) {
1409
1427
  * `deleteMode: 'hard'` still bypasses, exactly as the single-doc path does — a purge
1410
1428
  * is a deliberate physical delete, and it reaches the driver directly anyway.
1411
1429
  */
1412
- repo.on("before:bulkWrite", (context) => {
1430
+ const scopeSubOps = (context) => {
1413
1431
  if (options.soft === false || context.deleteMode === "hard") return;
1414
1432
  const ops = context.operations;
1415
1433
  if (!Array.isArray(ops)) return;
@@ -1440,7 +1458,8 @@ function softDeletePlugin(options = {}) {
1440
1458
  delete subOp[key];
1441
1459
  }
1442
1460
  }
1443
- }, { priority: HOOK_PRIORITY.POLICY });
1461
+ };
1462
+ for (const op of ["bulkWrite", "bulkUpsert"]) repo.on(`before:${op}`, scopeSubOps, { priority: HOOK_PRIORITY.POLICY });
1444
1463
  repo.on("before:deleteMany", async (context) => {
1445
1464
  if (context.deleteMode === "hard") return;
1446
1465
  if (options.soft !== false) {
@@ -1,5 +1,5 @@
1
- import { v as UserContext } from "./repository-odl03582.mjs";
2
- import { r as FieldPreset } from "./plugin-options-Zk46lD6F.mjs";
1
+ import { v as UserContext } from "./repository-DNHfjAZX.mjs";
2
+ import { r as FieldPreset } from "./plugin-options-CRhIvSzx.mjs";
3
3
  import mongoose, { Schema } from "mongoose";
4
4
  import { CacheAdapter } from "@classytic/repo-core/cache";
5
5
  import { HttpError } from "@classytic/repo-core/errors";
@@ -1,3 +1,6 @@
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";
1
4
  //#region src/capabilities.ts
2
5
  /**
3
6
  * Read the provenance of a capability descriptor's `transactions` flag.
@@ -52,7 +55,13 @@ const MONGOKIT_CAPABILITIES = Object.freeze({
52
55
  lean: true,
53
56
  lookupPopulate: true,
54
57
  lookupCoerce: true,
55
- streaming: true
58
+ streaming: true,
59
+ getByIdsOrdered: true,
60
+ scopedJoins: true,
61
+ paginationProperties: true,
62
+ bulkUpsert: true,
63
+ readModel: true,
64
+ mergeKeyset: true
56
65
  });
57
66
  /**
58
67
  * Driver `ServerType`s that positively imply a session-capable, transaction-
@@ -272,6 +281,9 @@ const SESSION_OPTIONS_INDEX = Object.freeze({
272
281
  aggregatePipelinePaginate: 0,
273
282
  lookupPopulate: 0,
274
283
  cursor: 1,
284
+ iterate: 1,
285
+ bulkUpsert: 1,
286
+ keysetCursor: 1,
275
287
  claim: 3,
276
288
  claimVersion: 3,
277
289
  applyTransition: 3,
@@ -381,11 +393,30 @@ function createTxBoundRepo(outer, session) {
381
393
  //#endregion
382
394
  //#region src/transaction.ts
383
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
+ /**
384
415
  * Run a callback inside a MongoDB transaction on the given connection.
385
416
  *
386
- * - Starts a session, runs `session.withTransaction(callback)` (which auto-retries
387
- * on `TransientTransactionError` and `UnknownTransactionCommitResult`), and
388
- * always ends the session in `finally`.
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}.
389
420
  * - When `allowFallback` is true and the deployment doesn't support transactions
390
421
  * (e.g. standalone MongoDB in dev), the callback runs once without a
391
422
  * transaction on the same session. `onFallback` is invoked with the original
@@ -406,7 +437,7 @@ function createTxBoundRepo(outer, session) {
406
437
  async function withTransaction(connection, callback, options = {}) {
407
438
  const session = await connection.startSession();
408
439
  try {
409
- return await session.withTransaction(() => callback(session), options.transactionOptions);
440
+ return await runTransaction(session, callback, options.transactionOptions);
410
441
  } catch (error) {
411
442
  const err = error;
412
443
  if (options.allowFallback && isTransactionUnsupported(err)) {
@@ -418,6 +449,97 @@ async function withTransaction(connection, callback, options = {}) {
418
449
  await session.endSession();
419
450
  }
420
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
+ }
421
543
  /**
422
544
  * Multi-repo transactional batch — every repo in `repos` becomes a
423
545
  * session-bound proxy inside the callback. Eliminates the per-call