@procedures/mongo 0.9.0 → 0.9.2

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.
@@ -0,0 +1,281 @@
1
+ /**
2
+ * The guard of an aggregation pipeline over shared collections (Graph's split plan, part P3). A
3
+ * pipeline of a caller reads only the documents of a filter (for example of one tenant or one
4
+ * partition), on each collection that it reads, and a write stamps its fields.
5
+ *
6
+ * - The filter goes before the first stage, into a stage that must be first (`$geoNear`,
7
+ * `$search`, `$vectorSearch`), into a `$match` with `$text`, and into each sub-pipeline
8
+ * (`$lookup`, `$graphLookup`, `$unionWith`, `$facet`).
9
+ * - A pipeline reads and writes only the collections of the scope (`from`, `coll`, `into`).
10
+ * - `$out` replaces a whole collection, so it is refused. `$merge` is refused unless the scope
11
+ * permits writes: then it gets the stamp, and its match key includes the stamp fields.
12
+ * - Only the stages of `ALLOWED_PIPELINE_STAGES` pass: the stages of the server are refused. The
13
+ * JavaScript operators of the server (`$function`, `$accumulator`, `$where`) are refused at each
14
+ * depth.
15
+ * - A stage has exactly one key, so a second key cannot hide a refused stage.
16
+ * - The function changes no part of the pipeline of the caller.
17
+ */
18
+
19
+ import type { Document } from "mongodb";
20
+ import { seconds, type Duration } from "@procedures/client";
21
+
22
+ /**
23
+ * The stages that a scoped pipeline can have: the stages that read and change the documents of
24
+ * the stream. Another stage is refused, so a stage of the server that a later MongoDB adds (for
25
+ * example `$listClusterCatalog`) is refused too. `$documents` (documents from the pipeline) and
26
+ * `$out` (it replaces a whole collection) are not in it.
27
+ */
28
+ export const ALLOWED_PIPELINE_STAGES: ReadonlySet<string> = new Set([
29
+ "$addFields",
30
+ "$bucket",
31
+ "$bucketAuto",
32
+ "$count",
33
+ "$densify",
34
+ "$facet",
35
+ "$fill",
36
+ "$geoNear",
37
+ "$graphLookup",
38
+ "$group",
39
+ "$limit",
40
+ "$lookup",
41
+ "$match",
42
+ "$merge",
43
+ "$project",
44
+ "$redact",
45
+ "$replaceRoot",
46
+ "$replaceWith",
47
+ "$sample",
48
+ "$search",
49
+ "$searchMeta",
50
+ "$set",
51
+ "$setWindowFields",
52
+ "$skip",
53
+ "$sort",
54
+ "$sortByCount",
55
+ "$unionWith",
56
+ "$unset",
57
+ "$unwind",
58
+ "$vectorSearch",
59
+ ]);
60
+
61
+ /** The operators that start JavaScript on the server. */
62
+ export const BLOCKED_PIPELINE_OPERATORS: ReadonlySet<string> = new Set(["$function", "$accumulator", "$where"]);
63
+
64
+ /** The scope of a pipeline. */
65
+ export interface PipelineScope {
66
+ /** The filter of the documents that the pipeline can read, on each collection that it reads. */
67
+ readonly match: Document;
68
+ /**
69
+ * The collections that the pipeline can read (`$lookup`, `$graphLookup`, `$unionWith`) and
70
+ * write (`$merge`). Each of them gets the filter. Give only the collections that the caller can
71
+ * read: a pipeline needs the same right as a direct read.
72
+ */
73
+ readonly collections: readonly string[];
74
+ /** The fields that each written document gets. With them, the pipeline can end in `$merge`. */
75
+ readonly stamp?: Document | undefined;
76
+ }
77
+
78
+ /** This function throws when a value has a JavaScript operator of the server, at any depth. */
79
+ export function assertNoBlockedOperators(value: unknown): void {
80
+ if (typeof value !== "object" || value === null) return;
81
+ if (Array.isArray(value)) {
82
+ for (const element of value) assertNoBlockedOperators(element);
83
+ return;
84
+ }
85
+ for (const [key, field] of Object.entries(value)) {
86
+ if (BLOCKED_PIPELINE_OPERATORS.has(key)) throw new Error(`The operator ${key} is not allowed in a scoped pipeline`);
87
+ assertNoBlockedOperators(field);
88
+ }
89
+ }
90
+
91
+ /** The filter of a selector of a stage, and the filter of the scope. */
92
+ function scoped(selector: unknown, match: Document): Document {
93
+ return typeof selector === "object" && selector !== null && !Array.isArray(selector) ? { ...(selector as Document), ...match } : { ...match };
94
+ }
95
+
96
+ /** The `equals` clauses of `$search` for the filter: each field must be a string, a number or a boolean. */
97
+ function searchFilter(match: Document): Document[] {
98
+ return Object.entries(match).map(([path, value]) => {
99
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
100
+ throw new Error(`$search can filter the scope only by equality: the field ${path} has a condition`);
101
+ }
102
+ return { equals: { path, value } };
103
+ });
104
+ }
105
+
106
+ /** This function refuses a collection outside the scope. */
107
+ function checkCollection(name: unknown, stage: string, scope: PipelineScope): void {
108
+ if (typeof name !== "string" || !scope.collections.includes(name)) {
109
+ throw new Error(`${stage} can read and write only the collections of the scope, not ${JSON.stringify(name)}`);
110
+ }
111
+ }
112
+
113
+ /** This function sets an own field (a key `__proto__` does not change the prototype). */
114
+ function put(target: Document, key: string, value: unknown): void {
115
+ Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
116
+ }
117
+
118
+ /** The fields that a pipeline cannot remove: the fields of the filter and of the stamp. */
119
+ function guardedFields(scope: PipelineScope): Set<string> {
120
+ return new Set([...Object.keys(scope.match), ...Object.keys(scope.stamp ?? {})].filter((key) => !key.startsWith("$")));
121
+ }
122
+
123
+ /** This function gives the pipeline with the filter of the scope on each read, and the stamp on each write. */
124
+ export function scopePipeline(pipeline: readonly Document[], scope: PipelineScope): Document[] {
125
+ if (!Array.isArray(pipeline)) throw new Error("A pipeline must be an array");
126
+ assertNoBlockedOperators(pipeline);
127
+ const guarded = guardedFields(scope);
128
+ const result: Document[] = [];
129
+ let matched = false;
130
+
131
+ for (const stage of pipeline) {
132
+ if (typeof stage !== "object" || stage === null || Array.isArray(stage) || Object.keys(stage).length !== 1) {
133
+ throw new Error("A pipeline stage must be an object with exactly one operator");
134
+ }
135
+ const name = Object.keys(stage)[0]!;
136
+ const body = stage[name] as unknown;
137
+ if (name === "$out") throw new Error("$out is not allowed in a scoped pipeline: it replaces the whole collection");
138
+ if (!ALLOWED_PIPELINE_STAGES.has(name)) throw new Error(`The stage ${name} is not allowed in a scoped pipeline`);
139
+ const sub: PipelineScope = { match: scope.match, collections: scope.collections };
140
+
141
+ // The stages that must be first take the filter themselves
142
+ if (name === "$geoNear") {
143
+ result.push({ $geoNear: { ...(body as Document), query: scoped((body as Document)["query"], scope.match) } });
144
+ matched = true;
145
+ continue;
146
+ }
147
+ if (name === "$vectorSearch") {
148
+ result.push({ $vectorSearch: { ...(body as Document), filter: scoped((body as Document)["filter"], scope.match) } });
149
+ matched = true;
150
+ continue;
151
+ }
152
+ if (name === "$search" || name === "$searchMeta") {
153
+ const search = { ...(body as Document) };
154
+ let compound: Document;
155
+ if (search["compound"] === undefined) {
156
+ const operator: Document = {};
157
+ for (const [key, value] of Object.entries(search)) if (key !== "index") operator[key] = value;
158
+ compound = { must: [operator], filter: [] };
159
+ for (const key of Object.keys(search)) if (key !== "index") delete search[key];
160
+ } else {
161
+ const existing = search["compound"] as Document;
162
+ compound = { ...existing, filter: Array.isArray(existing["filter"]) ? [...(existing["filter"] as Document[])] : [] };
163
+ }
164
+ (compound["filter"] as Document[]).push(...searchFilter(scope.match));
165
+ search["compound"] = compound;
166
+ result.push({ [name]: search });
167
+ matched = true;
168
+ continue;
169
+ }
170
+
171
+ if (!matched) {
172
+ result.push({ $match: { ...scope.match } });
173
+ matched = true;
174
+ }
175
+
176
+ if (name === "$lookup") {
177
+ const lookup = { ...(body as Document) };
178
+ checkCollection(lookup["from"], "$lookup", scope);
179
+ if (lookup["pipeline"] === undefined) {
180
+ // The form with localField and foreignField becomes the form with a pipeline and the filter
181
+ const local = lookup["localField"];
182
+ const foreign = lookup["foreignField"];
183
+ if (typeof local !== "string" || typeof foreign !== "string") throw new Error("$lookup needs localField and foreignField, or a pipeline");
184
+ delete lookup["localField"];
185
+ delete lookup["foreignField"];
186
+ lookup["let"] = { ...(lookup["let"] as Document | undefined), scopeLocalValue: `$${local}` };
187
+ lookup["pipeline"] = [{ $match: { ...scope.match } }, { $match: { $expr: { $eq: [`$${foreign}`, "$$scopeLocalValue"] } } }];
188
+ } else {
189
+ lookup["pipeline"] = scopePipeline(lookup["pipeline"] as Document[], sub);
190
+ }
191
+ result.push({ $lookup: lookup });
192
+ continue;
193
+ }
194
+ if (name === "$graphLookup") {
195
+ const graph = body as Document;
196
+ checkCollection(graph["from"], "$graphLookup", scope);
197
+ result.push({ $graphLookup: { ...graph, restrictSearchWithMatch: scoped(graph["restrictSearchWithMatch"], scope.match) } });
198
+ continue;
199
+ }
200
+ if (name === "$unionWith") {
201
+ const union: Document = typeof body === "string" ? { coll: body, pipeline: [] } : { ...(body as Document) };
202
+ checkCollection(union["coll"], "$unionWith", scope);
203
+ union["pipeline"] = scopePipeline((union["pipeline"] as Document[] | undefined) ?? [], sub);
204
+ result.push({ $unionWith: union });
205
+ continue;
206
+ }
207
+ if (name === "$facet") {
208
+ const facet: Document = {};
209
+ for (const [branch, branchPipeline] of Object.entries(body as Document)) {
210
+ put(facet, branch, scopePipeline(branchPipeline as Document[], sub));
211
+ }
212
+ result.push({ $facet: facet });
213
+ continue;
214
+ }
215
+ if (name === "$merge") {
216
+ if (scope.stamp === undefined) throw new Error("$merge is not allowed in a read-only scoped pipeline");
217
+ const merge: Document = typeof body === "string" ? { into: body } : { ...(body as Document) };
218
+ const into = merge["into"];
219
+ if (typeof into === "object" && into !== null && (into as Document)["db"] !== undefined) {
220
+ throw new Error("A $merge into another database is not allowed in a scoped pipeline");
221
+ }
222
+ checkCollection(typeof into === "object" && into !== null ? (into as Document)["coll"] : into, "$merge", scope);
223
+ const on = merge["on"] ?? "_id";
224
+ const keys = typeof on === "string" ? [on] : [...(on as string[])];
225
+ for (const field of Object.keys(scope.stamp)) if (!keys.includes(field)) keys.push(field);
226
+ merge["on"] = keys;
227
+ // The stamp comes last, so an update of the pipeline cannot write another value
228
+ if (Array.isArray(merge["whenMatched"])) merge["whenMatched"] = [...(merge["whenMatched"] as Document[]), { $addFields: { ...scope.stamp } }];
229
+ result.push({ $addFields: { ...scope.stamp } }, { $merge: merge });
230
+ continue;
231
+ }
232
+ if (name === "$unset") {
233
+ const fields = Array.isArray(body) ? body : [body];
234
+ for (const field of fields) if (typeof field === "string" && guarded.has(field)) throw new Error(`A scoped pipeline cannot $unset ${field}`);
235
+ result.push(stage);
236
+ continue;
237
+ }
238
+ if (name === "$match" && typeof body === "object" && body !== null && (body as Document)["$text"] !== undefined) {
239
+ result.push({ $match: { ...(body as Document), ...scope.match } });
240
+ continue;
241
+ }
242
+ result.push(stage);
243
+ }
244
+
245
+ if (!matched) result.unshift({ $match: { ...scope.match } });
246
+ return result;
247
+ }
248
+
249
+ /** The limits of an aggregation of a caller. */
250
+ export interface PipelineLimits {
251
+ /** The longest time of the aggregation (default 10 s). A plain number is a `TypeError`. */
252
+ readonly maxTime?: Duration | undefined;
253
+ /** The most documents of the result (default 5000). */
254
+ readonly maxResults?: number | undefined;
255
+ }
256
+
257
+ /** The default limits of an aggregation. */
258
+ export const DEFAULT_PIPELINE_LIMITS: Readonly<{ maxTime: Duration; maxResults: number }> = Object.freeze({ maxTime: seconds(10), maxResults: 5_000 });
259
+
260
+ /**
261
+ * This function adds a `$limit` of one more than the result limit, so a cut result is known. A
262
+ * pipeline that ends in `$merge` writes and gives no documents, so it gets no `$limit`.
263
+ */
264
+ export function boundPipeline(pipeline: readonly Document[], maxResults: number): { pipeline: Document[]; limited: boolean } {
265
+ const last = pipeline[pipeline.length - 1];
266
+ const lastStage = last && typeof last === "object" ? Object.keys(last)[0] : undefined;
267
+ if (lastStage === "$merge") return { pipeline: [...pipeline], limited: false };
268
+ return { pipeline: [...pipeline, { $limit: maxResults + 1 }], limited: true };
269
+ }
270
+
271
+ /** This function gives at most `maxResults` documents, and tells if it cut the result. */
272
+ export function cutResults<T>(results: T[], maxResults: number, limited: boolean): { docs: T[]; truncated: boolean } {
273
+ if (!limited || results.length <= maxResults) return { docs: results, truncated: false };
274
+ return { docs: results.slice(0, maxResults), truncated: true };
275
+ }
276
+
277
+ /** True for the error of an operation that passed its `maxTimeMS` (code 50, MaxTimeMSExpired). */
278
+ export function isTimeLimitError(error: unknown): boolean {
279
+ const value = error as { code?: unknown; codeName?: unknown } | null;
280
+ return value?.code === 50 || value?.codeName === "MaxTimeMSExpired";
281
+ }