@classytic/mongokit 3.40.0 → 3.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -0
- package/dist/{PaginationEngine-D-P19Y9m.d.mts → PaginationEngine-CRHyuUtw.d.mts} +17 -3
- package/dist/{PaginationEngine-Dvl8jcRY.mjs → PaginationEngine-DZnkbSrr.mjs} +299 -140
- package/dist/{Repository-BMgPSkqZ.mjs → Repository-DlhPbXlG.mjs} +571 -175
- package/dist/{Repository-CR8Y9g2E.d.mts → Repository-pnLw1rG5.d.mts} +57 -16
- package/dist/actions/index.d.mts +1 -1
- package/dist/actions/index.mjs +1 -1
- package/dist/adapter/index.mjs +1 -1
- package/dist/{adapter-BrVfPFSW.mjs → adapter-CkMoMF83.mjs} +1 -1
- package/dist/ai/index.d.mts +1 -1
- package/dist/better-auth/index.mjs +2 -2
- package/dist/{create-repository-CAle1MSU.d.mts → create-repository-BUx9vfG9.d.mts} +5 -5
- package/dist/{create-repository-BzBUMvd1.mjs → create-repository-Bg0H5rte.mjs} +2 -2
- package/dist/{index-CmjNyulu.d.mts → index-SYF8N28D.d.mts} +37 -19
- package/dist/index.d.mts +69 -15
- package/dist/index.mjs +57 -11
- package/dist/join-scope-DSAznE8E.mjs +168 -0
- package/dist/{mongooseToJsonSchema-waUVQwK4.mjs → mongooseToJsonSchema-C3k4ide0.mjs} +5 -1
- package/dist/{multi-tenant.plugin-CBgCo9im.d.mts → multi-tenant.plugin-a3UhQARj.d.mts} +2 -2
- package/dist/{operations-CrRIoc8Z.d.mts → operations-XiFgG5US.d.mts} +36 -13
- package/dist/outbox/index.d.mts +1 -1
- package/dist/outbox/index.mjs +1 -1
- package/dist/pagination/PaginationEngine.d.mts +2 -2
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/pagination/utils/limits.d.mts +1 -1
- package/dist/{pagination-BpIv2bdS.d.mts → pagination-D6psses0.d.mts} +116 -13
- package/dist/{plugin-options-Zk46lD6F.d.mts → plugin-options-CRhIvSzx.d.mts} +2 -2
- package/dist/plugins/index.d.mts +3 -3
- package/dist/plugins/index.mjs +2 -2
- package/dist/query-defaults-BxUcSOoF.mjs +208 -0
- package/dist/read-model/index.d.mts +66 -0
- package/dist/read-model/index.mjs +193 -0
- package/dist/{repository-odl03582.d.mts → repository-DNHfjAZX.d.mts} +16 -2
- package/dist/sync/index.d.mts +15 -2
- package/dist/sync/index.mjs +38 -1
- package/dist/testkit/index.d.mts +40 -3
- package/dist/testkit/index.mjs +113 -2
- package/dist/{timestamp.plugin-DF0gJB6p.mjs → timestamp.plugin-DvZlnKEc.mjs} +50 -31
- package/dist/{to-plain-k7SjNlSS.d.mts → to-plain-CjgEInWV.d.mts} +2 -2
- package/dist/{transaction-B5L9glO0.mjs → transaction-DmiB4k2P.mjs} +155 -6
- package/dist/{update-DG0fCwRJ.mjs → update-WgeAUJ_I.mjs} +44 -22
- package/dist/utils/index.d.mts +1 -1
- package/dist/utils/index.mjs +1 -1
- package/dist/{validation-chain.plugin-DYp9E7K2.d.mts → validation-chain.plugin-CTcNJCmV.d.mts} +4 -4
- package/dist/{validation-chain.plugin-BLY2gLEk.mjs → validation-chain.plugin-D1WsGCYr.mjs} +15 -3
- package/package.json +10 -5
package/dist/testkit/index.d.mts
CHANGED
|
@@ -1,7 +1,44 @@
|
|
|
1
|
-
import { r as Repository } from "../Repository-
|
|
2
|
-
import { n as CreateRepositoryConfig } from "../create-repository-
|
|
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 };
|
package/dist/testkit/index.mjs
CHANGED
|
@@ -1,5 +1,116 @@
|
|
|
1
|
-
import { t as createRepository } from "../create-repository-
|
|
1
|
+
import { t as createRepository } from "../create-repository-Bg0H5rte.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-
|
|
1
|
+
import { i as ALL_OPERATIONS, o as OP_REGISTRY, t as HOOK_PRIORITY } from "./Repository-DlhPbXlG.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
|
|
1079
|
-
|
|
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:
|
|
1119
|
+
reason: decision.bypass
|
|
1085
1120
|
});
|
|
1086
1121
|
return;
|
|
1087
1122
|
}
|
|
1088
|
-
|
|
1089
|
-
if (
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
}
|
|
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-
|
|
2
|
-
import { r as FieldPreset } from "./plugin-options-
|
|
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,7 @@
|
|
|
1
|
+
import { t as createError } from "./error-BuByZwKU.mjs";
|
|
2
|
+
import { d as setTransactionDeadline } from "./query-defaults-BxUcSOoF.mjs";
|
|
3
|
+
import mongoose from "mongoose";
|
|
4
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
1
5
|
//#region src/capabilities.ts
|
|
2
6
|
/**
|
|
3
7
|
* Read the provenance of a capability descriptor's `transactions` flag.
|
|
@@ -52,7 +56,13 @@ const MONGOKIT_CAPABILITIES = Object.freeze({
|
|
|
52
56
|
lean: true,
|
|
53
57
|
lookupPopulate: true,
|
|
54
58
|
lookupCoerce: true,
|
|
55
|
-
streaming: true
|
|
59
|
+
streaming: true,
|
|
60
|
+
getByIdsOrdered: true,
|
|
61
|
+
scopedJoins: true,
|
|
62
|
+
paginationProperties: true,
|
|
63
|
+
bulkUpsert: true,
|
|
64
|
+
readModel: true,
|
|
65
|
+
mergeKeyset: true
|
|
56
66
|
});
|
|
57
67
|
/**
|
|
58
68
|
* Driver `ServerType`s that positively imply a session-capable, transaction-
|
|
@@ -272,6 +282,9 @@ const SESSION_OPTIONS_INDEX = Object.freeze({
|
|
|
272
282
|
aggregatePipelinePaginate: 0,
|
|
273
283
|
lookupPopulate: 0,
|
|
274
284
|
cursor: 1,
|
|
285
|
+
iterate: 1,
|
|
286
|
+
bulkUpsert: 1,
|
|
287
|
+
keysetCursor: 1,
|
|
275
288
|
claim: 3,
|
|
276
289
|
claimVersion: 3,
|
|
277
290
|
applyTransition: 3,
|
|
@@ -381,11 +394,30 @@ function createTxBoundRepo(outer, session) {
|
|
|
381
394
|
//#endregion
|
|
382
395
|
//#region src/transaction.ts
|
|
383
396
|
/**
|
|
397
|
+
* Cross-repo transaction helper.
|
|
398
|
+
*
|
|
399
|
+
* `Repository#withTransaction` is convenient when every write in a transaction
|
|
400
|
+
* lives on the same repository, but real workflows usually span several:
|
|
401
|
+
*
|
|
402
|
+
* await withTransaction(connection, async (session) => {
|
|
403
|
+
* const txn = await revenue.transaction.create(data, { session });
|
|
404
|
+
* await ledger.entry.create(journal, { session });
|
|
405
|
+
* await revenue.transaction.verify(txn._id, {}, { session });
|
|
406
|
+
* });
|
|
407
|
+
*
|
|
408
|
+
* This module-level helper accepts a Mongoose connection (or anything with a
|
|
409
|
+
* compatible `startSession()`), so hosts don't need to arbitrarily pick one
|
|
410
|
+
* repository to hang the transaction off.
|
|
411
|
+
*
|
|
412
|
+
* The Repository instance method delegates to this function — one source of
|
|
413
|
+
* truth for retry semantics, standalone fallback, and session lifecycle.
|
|
414
|
+
*/
|
|
415
|
+
/**
|
|
384
416
|
* Run a callback inside a MongoDB transaction on the given connection.
|
|
385
417
|
*
|
|
386
|
-
* - Starts a session
|
|
387
|
-
*
|
|
388
|
-
*
|
|
418
|
+
* - Starts a session and runs the callback in a transaction with the driver's retry rules
|
|
419
|
+
* (`TransientTransactionError` → retry the attempt, `UnknownTransactionCommitResult` → retry
|
|
420
|
+
* the commit), ending the session in `finally`. See {@link runTransaction}.
|
|
389
421
|
* - When `allowFallback` is true and the deployment doesn't support transactions
|
|
390
422
|
* (e.g. standalone MongoDB in dev), the callback runs once without a
|
|
391
423
|
* transaction on the same session. `onFallback` is invoked with the original
|
|
@@ -406,7 +438,13 @@ function createTxBoundRepo(outer, session) {
|
|
|
406
438
|
async function withTransaction(connection, callback, options = {}) {
|
|
407
439
|
const session = await connection.startSession();
|
|
408
440
|
try {
|
|
409
|
-
|
|
441
|
+
assertSessionSurface(session, [...DRIVEN, "endSession"]);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
if (typeof session.endSession === "function") await session.endSession();
|
|
444
|
+
throw err;
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
return await runTransaction(session, callback, options.transactionOptions);
|
|
410
448
|
} catch (error) {
|
|
411
449
|
const err = error;
|
|
412
450
|
if (options.allowFallback && isTransactionUnsupported(err)) {
|
|
@@ -418,6 +456,117 @@ async function withTransaction(connection, callback, options = {}) {
|
|
|
418
456
|
await session.endSession();
|
|
419
457
|
}
|
|
420
458
|
}
|
|
459
|
+
const TRANSACTION_ERROR_CODES = {
|
|
460
|
+
/** The session lacks a method the transaction loop drives (a partial or wrapped fake). */
|
|
461
|
+
SESSION_INCOMPLETE: "mongokit.transaction.session_incomplete" };
|
|
462
|
+
const DRIVEN = [
|
|
463
|
+
"startTransaction",
|
|
464
|
+
"commitTransaction",
|
|
465
|
+
"abortTransaction",
|
|
466
|
+
"inTransaction"
|
|
467
|
+
];
|
|
468
|
+
/** Refuse, with the missing names, a session that cannot be driven, before calling anything on it. */
|
|
469
|
+
function assertSessionSurface(session, required) {
|
|
470
|
+
const s = session ?? {};
|
|
471
|
+
const missing = required.filter((name) => typeof s[name] !== "function");
|
|
472
|
+
if (missing.length === 0) return;
|
|
473
|
+
throw createError(500, `[mongokit] the transaction session lacks ${missing.join(", ")}; pass a driver ClientSession`, {
|
|
474
|
+
code: TRANSACTION_ERROR_CODES.SESSION_INCOMPLETE,
|
|
475
|
+
meta: { missing }
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
const MAX_TRANSACTION_TIMEOUT_MS = 12e4;
|
|
479
|
+
const BACKOFF_INITIAL_MS = 5;
|
|
480
|
+
const BACKOFF_MAX_MS = 500;
|
|
481
|
+
const MAX_TIME_MS_EXPIRED = 50;
|
|
482
|
+
function hasLabel(err, label) {
|
|
483
|
+
const e = err;
|
|
484
|
+
if (typeof e?.hasErrorLabel === "function") return e.hasErrorLabel(label);
|
|
485
|
+
return Array.isArray(e?.errorLabels) && e.errorLabels.includes(label);
|
|
486
|
+
}
|
|
487
|
+
function isMaxTimeMSExpired(err) {
|
|
488
|
+
const e = err;
|
|
489
|
+
return e?.code === MAX_TIME_MS_EXPIRED || e?.writeConcernError?.code === MAX_TIME_MS_EXPIRED;
|
|
490
|
+
}
|
|
491
|
+
function timeoutError(cause, csot) {
|
|
492
|
+
if (!csot || cause instanceof mongoose.mongo.MongoOperationTimeoutError) return cause;
|
|
493
|
+
const error = new mongoose.mongo.MongoOperationTimeoutError("Timed out during withTransaction", { cause: cause instanceof Error ? cause : void 0 });
|
|
494
|
+
if (cause instanceof mongoose.mongo.MongoError) for (const label of cause.errorLabels) error.addErrorLabel(label);
|
|
495
|
+
return error;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* The driver's convenient-transaction algorithm (start → callback → commit, label-based retry,
|
|
499
|
+
* jittered backoff, a deadline), run here so the driver never attaches a session `timeoutContext`:
|
|
500
|
+
* with one, every legacy bulk write (`insertMany`, `bulkWrite`) in the transaction is refused once
|
|
501
|
+
* the client carries `timeoutMS` (driver <= 7.7: the bulk path re-resolves the inherited client
|
|
502
|
+
* `timeoutMS` as a per-op one). Each operation keeps its own per-op CSOT budget instead.
|
|
503
|
+
* Deadline: `transactionOptions.timeoutMS`, else the client's `timeoutMS`, else 120 s (as the driver).
|
|
504
|
+
* Pinned by `tests/integration/csot-transaction-matrix.test.ts`.
|
|
505
|
+
*/
|
|
506
|
+
async function runTransaction(session, callback, transactionOptions = {}) {
|
|
507
|
+
assertSessionSurface(session, DRIVEN);
|
|
508
|
+
const { timeoutMS: explicitTimeout, ...startOptions } = transactionOptions;
|
|
509
|
+
const clientTimeout = session.timeoutMS;
|
|
510
|
+
const timeoutMS = explicitTimeout ?? clientTimeout;
|
|
511
|
+
const csot = timeoutMS != null;
|
|
512
|
+
const deadline = performance.now() + (timeoutMS ?? MAX_TRANSACTION_TIMEOUT_MS);
|
|
513
|
+
let lastError;
|
|
514
|
+
if (csot) setTransactionDeadline(session, deadline);
|
|
515
|
+
try {
|
|
516
|
+
for (let attempt = 0;; attempt++) {
|
|
517
|
+
if (attempt > 0) {
|
|
518
|
+
const backoff = Math.random() * Math.min(BACKOFF_INITIAL_MS * 1.5 ** (attempt - 1), BACKOFF_MAX_MS);
|
|
519
|
+
if (performance.now() + backoff >= deadline) throw timeoutError(lastError, csot);
|
|
520
|
+
await setTimeout$1(backoff);
|
|
521
|
+
}
|
|
522
|
+
session.startTransaction(startOptions);
|
|
523
|
+
let result;
|
|
524
|
+
try {
|
|
525
|
+
result = await callback(session);
|
|
526
|
+
if (!session.inTransaction()) return result;
|
|
527
|
+
} catch (err) {
|
|
528
|
+
lastError = err;
|
|
529
|
+
await abortKeepingError(session, err);
|
|
530
|
+
if (err instanceof mongoose.mongo.MongoError && hasLabel(err, "TransientTransactionError")) {
|
|
531
|
+
if (performance.now() >= deadline) throw timeoutError(err, csot);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
throw err;
|
|
535
|
+
}
|
|
536
|
+
for (;;) {
|
|
537
|
+
const left = deadline - performance.now();
|
|
538
|
+
if (csot && left <= 0) {
|
|
539
|
+
const late = new mongoose.mongo.MongoOperationTimeoutError("Transaction budget exhausted before commit; the transaction was aborted, nothing was committed");
|
|
540
|
+
await abortKeepingError(session, late);
|
|
541
|
+
throw late;
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
await session.commitTransaction(csot ? { timeoutMS: Math.max(1, Math.floor(left)) } : void 0);
|
|
545
|
+
return result;
|
|
546
|
+
} catch (err) {
|
|
547
|
+
lastError = err;
|
|
548
|
+
if (hasLabel(err, "UnknownTransactionCommitResult") && !isMaxTimeMSExpired(err)) {
|
|
549
|
+
if (performance.now() >= deadline) throw timeoutError(err, csot);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
if (hasLabel(err, "TransientTransactionError")) break;
|
|
553
|
+
throw err;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
} finally {
|
|
558
|
+
if (csot) setTransactionDeadline(session, void 0);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/** Abort if still in progress; an abort failure rides along as `abortError`, never replacing `original`. */
|
|
562
|
+
async function abortKeepingError(session, original) {
|
|
563
|
+
if (!session.inTransaction()) return;
|
|
564
|
+
try {
|
|
565
|
+
await session.abortTransaction();
|
|
566
|
+
} catch (abortError) {
|
|
567
|
+
if (original && typeof original === "object") Object.assign(original, { abortError });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
421
570
|
/**
|
|
422
571
|
* Multi-repo transactional batch — every repo in `repos` becomes a
|
|
423
572
|
* session-bound proxy inside the callback. Eliminates the per-call
|
|
@@ -530,4 +679,4 @@ function supportsTransactions(connection) {
|
|
|
530
679
|
return resolveTransactionSupport(connection) !== "no";
|
|
531
680
|
}
|
|
532
681
|
//#endregion
|
|
533
|
-
export {
|
|
682
|
+
export { supportsTransactions as a, MONGOKIT_CAPABILITIES as c, probeMongoCapabilities as d, resetCapabilityProbeCache as f, transactionResolutionOf as h, runTransaction as i, capabilitiesForSupport as l, resolveTransactionSupport as m, batchTransaction as n, withTransaction as o, resolveMongoCapabilities as p, isTransactionUnsupported as r, createTxBoundRepo as s, TRANSACTION_ERROR_CODES as t, classifyHelloReply as u };
|