@procedures/mongo 0.2.2 → 0.3.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +74 -3
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/index.d.ts +2 -2
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/module.d.ts.map +1 -1
  8. package/dist/module.js +9 -0
  9. package/dist/module.js.map +1 -1
  10. package/dist/procedures/documents.aggregate.d.ts +5 -0
  11. package/dist/procedures/documents.aggregate.d.ts.map +1 -1
  12. package/dist/procedures/documents.aggregate.js +36 -3
  13. package/dist/procedures/documents.aggregate.js.map +1 -1
  14. package/dist/procedures/documents.bulk.d.ts +42 -0
  15. package/dist/procedures/documents.bulk.d.ts.map +1 -0
  16. package/dist/procedures/documents.bulk.js +159 -0
  17. package/dist/procedures/documents.bulk.js.map +1 -0
  18. package/dist/procedures/documents.distinct.d.ts +22 -0
  19. package/dist/procedures/documents.distinct.d.ts.map +1 -0
  20. package/dist/procedures/documents.distinct.js +33 -0
  21. package/dist/procedures/documents.distinct.js.map +1 -0
  22. package/dist/procedures/documents.explain.d.ts +41 -0
  23. package/dist/procedures/documents.explain.d.ts.map +1 -0
  24. package/dist/procedures/documents.explain.js +111 -0
  25. package/dist/procedures/documents.explain.js.map +1 -0
  26. package/dist/procedures/index.d.ts +4 -0
  27. package/dist/procedures/index.d.ts.map +1 -1
  28. package/dist/procedures/index.js +5 -0
  29. package/dist/procedures/index.js.map +1 -1
  30. package/dist/procedures/watch.d.ts +59 -0
  31. package/dist/procedures/watch.d.ts.map +1 -0
  32. package/dist/procedures/watch.js +149 -0
  33. package/dist/procedures/watch.js.map +1 -0
  34. package/dist/procedures.types.d.ts +24 -3
  35. package/dist/procedures.types.d.ts.map +1 -1
  36. package/dist/storage/mongo-storage.d.ts +1 -0
  37. package/dist/storage/mongo-storage.d.ts.map +1 -1
  38. package/dist/storage/mongo-storage.js +4 -0
  39. package/dist/storage/mongo-storage.js.map +1 -1
  40. package/dist/types.d.ts +20 -2
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +4 -4
  43. package/src/index.ts +10 -0
  44. package/src/module.ts +9 -0
  45. package/src/procedures/documents.aggregate.ts +34 -3
  46. package/src/procedures/documents.bulk.ts +192 -0
  47. package/src/procedures/documents.distinct.ts +57 -0
  48. package/src/procedures/documents.explain.ts +144 -0
  49. package/src/procedures/index.ts +6 -0
  50. package/src/procedures/watch.ts +184 -0
  51. package/src/procedures.types.ts +25 -0
  52. package/src/storage/mongo-storage.ts +5 -0
  53. package/src/types.ts +22 -2
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Procedure: mongo.documents.bulk (roadmap R44)
3
+ * Several writes in one request: insertOne, updateOne, updateMany, deleteOne and deleteMany
4
+ *
5
+ * The operations have the form of the `bulkWrite` of the driver, and run in order by default.
6
+ * In order, the first failure stops the rest. With `ordered: false`, the server runs each
7
+ * operation, and the error tells which ones failed.
8
+ *
9
+ * The rules of `update` and `delete` (deep dive DATA-4) apply to each operation: an empty filter
10
+ * of an update or a delete needs `confirm: true`, and an unknown field is an error.
11
+ */
12
+
13
+ import { createProcedure, zodAdapter, type Effects, type Procedure, type ProcedureContext } from "@procedures/client";
14
+ import type { AnyBulkWriteOperation, Document } from "mongodb";
15
+ import { z } from "zod";
16
+ import { collectionFor, copyScope, SCOPE_FIELDS, type ScopeInput } from "../scope.js";
17
+ import { described, documentObject, scopeFields } from "./input-schemas.js";
18
+ import type { BulkOperation, DocumentQuery, DocumentUpdate } from "../types.js";
19
+
20
+ // Input/Output types
21
+ interface BulkInput extends ScopeInput {
22
+ /** The writes, in order */
23
+ operations: BulkOperation[];
24
+ /** True (the default): the first failure stops the rest */
25
+ ordered?: boolean;
26
+ /** Set to true to use an empty filter in an update or a delete */
27
+ confirm?: boolean;
28
+ }
29
+
30
+ interface BulkOutput {
31
+ ok: boolean;
32
+ insertedCount: number;
33
+ matchedCount: number;
34
+ modifiedCount: number;
35
+ deletedCount: number;
36
+ upsertedCount: number;
37
+ /** The ids of the inserted documents, as text, by the index of their operation */
38
+ insertedIds: Record<string, string>;
39
+ /** The ids of the documents that an upsert made, as text, by the index of their operation */
40
+ upsertedIds: Record<string, string>;
41
+ }
42
+
43
+ const PROCEDURE = "mongo.documents.bulk";
44
+ const INPUT_FIELDS = new Set<string>(["operations", "ordered", "confirm", ...SCOPE_FIELDS]);
45
+
46
+ /** The fields of the body of each kind of operation, and the fields that it needs. */
47
+ const KINDS: Record<string, { fields: readonly string[]; required: readonly string[] }> = {
48
+ insertOne: { fields: ["document"], required: ["document"] },
49
+ updateOne: { fields: ["filter", "update", "upsert"], required: ["filter", "update"] },
50
+ updateMany: { fields: ["filter", "update", "upsert"], required: ["filter", "update"] },
51
+ deleteOne: { fields: ["filter"], required: ["filter"] },
52
+ deleteMany: { fields: ["filter"], required: ["filter"] },
53
+ };
54
+
55
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
56
+ return typeof value === "object" && value !== null && !Array.isArray(value);
57
+ }
58
+
59
+ /** This function checks one operation, and gives it in the form of the driver. */
60
+ function parseOperation(raw: unknown, index: number, confirm: boolean): BulkOperation {
61
+ const at = `${PROCEDURE}: operations[${index}]`;
62
+ if (!isPlainObject(raw) || Object.keys(raw).length !== 1) {
63
+ throw new Error(`${at} must be an object with one of the keys ${Object.keys(KINDS).join(", ")}`);
64
+ }
65
+ const kind = Object.keys(raw)[0]!;
66
+ const spec = KINDS[kind];
67
+ if (!spec) throw new Error(`${at}: unknown operation "${kind}". The operations are: ${Object.keys(KINDS).join(", ")}`);
68
+ const body = raw[kind];
69
+ if (!isPlainObject(body)) throw new Error(`${at}.${kind} must be an object`);
70
+ for (const key of Object.keys(body)) {
71
+ if (!spec.fields.includes(key)) throw new Error(`${at}.${kind}: unknown field "${key}". The fields are: ${spec.fields.join(", ")}`);
72
+ }
73
+ for (const key of spec.required) {
74
+ if (body[key] === undefined) throw new Error(`${at}.${kind}: ${key} is required`);
75
+ }
76
+ if (kind === "insertOne") {
77
+ if (!isPlainObject(body["document"])) throw new Error(`${at}.insertOne: document must be an object`);
78
+ return { insertOne: { document: body["document"] } };
79
+ }
80
+ const filter = body["filter"];
81
+ if (!isPlainObject(filter)) throw new Error(`${at}.${kind}: filter must be an object`);
82
+ if (Object.keys(filter).length === 0 && !confirm) {
83
+ throw new Error(`${at}.${kind}: an empty filter matches every document. Set confirm: true to use it.`);
84
+ }
85
+ if (kind === "deleteOne") return { deleteOne: { filter: filter as DocumentQuery } };
86
+ if (kind === "deleteMany") return { deleteMany: { filter: filter as DocumentQuery } };
87
+ const update = body["update"];
88
+ if (typeof update !== "object" || update === null) {
89
+ throw new Error(`${at}.${kind}: update must be an object (or a pipeline array)`);
90
+ }
91
+ const upsert = body["upsert"];
92
+ if (upsert !== undefined && typeof upsert !== "boolean") throw new Error(`${at}.${kind}: upsert must be a boolean`);
93
+ const operation = { filter: filter as DocumentQuery, update: update as DocumentUpdate, ...(upsert !== undefined ? { upsert } : {}) };
94
+ return kind === "updateOne" ? { updateOne: operation } : { updateMany: operation };
95
+ }
96
+
97
+ /** This function checks the input of a bulk write. */
98
+ export function parseBulkInput(data: unknown): BulkInput {
99
+ if (!isPlainObject(data)) throw new Error(`${PROCEDURE}: input must be an object`);
100
+ for (const key of Object.keys(data)) {
101
+ if (!INPUT_FIELDS.has(key)) throw new Error(`${PROCEDURE}: unknown field "${key}". The fields are: ${[...INPUT_FIELDS].join(", ")}`);
102
+ }
103
+ for (const field of ["ordered", "confirm"]) {
104
+ if (data[field] !== undefined && typeof data[field] !== "boolean") throw new Error(`${PROCEDURE}: ${field} must be a boolean`);
105
+ }
106
+ const confirm = data["confirm"] === true;
107
+ const operations = data["operations"];
108
+ if (!Array.isArray(operations) || operations.length === 0) {
109
+ throw new Error(`${PROCEDURE}: operations must be a list with at least one operation`);
110
+ }
111
+ const result: BulkInput = { operations: operations.map((operation, index) => parseOperation(operation, index, confirm)) };
112
+ copyScope(data, result);
113
+ if (data["ordered"] !== undefined) result.ordered = data["ordered"] as boolean;
114
+ if (confirm) result.confirm = true;
115
+ return result;
116
+ }
117
+
118
+ /** The ids of a result, as text, by the index of their operation. */
119
+ function idsAsText(ids: Record<number, unknown> | undefined): Record<string, string> {
120
+ return Object.fromEntries(Object.entries(ids ?? {}).map(([index, id]) => [index, String(id)]));
121
+ }
122
+
123
+ // Schemas
124
+ const operationBody = (fields: Record<string, z.ZodType>) => z.object(fields);
125
+ const filterField = documentObject().describe("The filter of the documents. An empty filter needs confirm: true.");
126
+ const updateField = z
127
+ .union([documentObject(), z.array(documentObject())])
128
+ .describe('The update operations, for example { $set: { status: "done" } }, or a pipeline');
129
+ const upsertField = z.boolean().optional().describe("Insert a document when none matches");
130
+
131
+ const bulkInputSchema = described<BulkInput>(
132
+ parseBulkInput,
133
+ z.object({
134
+ ...scopeFields(),
135
+ operations: z
136
+ .array(
137
+ z.union([
138
+ z.object({ insertOne: operationBody({ document: documentObject().describe("The new document") }) }),
139
+ z.object({ updateOne: operationBody({ filter: filterField, update: updateField, upsert: upsertField }) }),
140
+ z.object({ updateMany: operationBody({ filter: filterField, update: updateField, upsert: upsertField }) }),
141
+ z.object({ deleteOne: operationBody({ filter: filterField }) }),
142
+ z.object({ deleteMany: operationBody({ filter: filterField }) }),
143
+ ])
144
+ )
145
+ .min(1)
146
+ .describe("The writes, in order: each one is { insertOne }, { updateOne }, { updateMany }, { deleteOne } or { deleteMany }"),
147
+ ordered: z.boolean().optional().describe("True (the default): the first failure stops the rest. False: the server tries each write."),
148
+ confirm: z.boolean().optional().describe("Set to true to use an empty filter, which matches every document, in an update or a delete"),
149
+ })
150
+ );
151
+ const bulkOutputSchema = zodAdapter<BulkOutput>(
152
+ z.looseObject({
153
+ ok: z.boolean().describe("True when the server confirmed the writes"),
154
+ insertedCount: z.number().describe("The number of inserted documents"),
155
+ matchedCount: z.number().describe("The number of documents that the updates matched"),
156
+ modifiedCount: z.number().describe("The number of documents that the updates changed"),
157
+ deletedCount: z.number().describe("The number of deleted documents"),
158
+ upsertedCount: z.number().describe("The number of documents that an upsert made"),
159
+ insertedIds: z.record(z.string(), z.string()).describe("The ids of the inserted documents, as text, by the index of their operation"),
160
+ upsertedIds: z.record(z.string(), z.string()).describe("The ids of the upserted documents, as text, by the index of their operation"),
161
+ })
162
+ );
163
+
164
+ export const bulkProcedure: Procedure<
165
+ BulkInput,
166
+ BulkOutput,
167
+ { description: string; effects?: Effects }
168
+ > = createProcedure()
169
+ .path(["mongo", "documents", "bulk"])
170
+ .input(bulkInputSchema)
171
+ .output(bulkOutputSchema)
172
+ .meta({
173
+ description: "Several writes in one request: insertOne, updateOne, updateMany, deleteOne and deleteMany, in order by default",
174
+ effects: { reads: ["mongo", "network"], writes: ["mongo", "network"] },
175
+ })
176
+ .handler(async (input: BulkInput, ctx: ProcedureContext) => {
177
+ const { collection } = await collectionFor(input, ctx);
178
+ const result = await collection.bulkWrite(input.operations as AnyBulkWriteOperation<Document>[], { ordered: input.ordered ?? true });
179
+ return {
180
+ ok: result.ok === 1,
181
+ insertedCount: result.insertedCount,
182
+ matchedCount: result.matchedCount,
183
+ modifiedCount: result.modifiedCount,
184
+ deletedCount: result.deletedCount,
185
+ upsertedCount: result.upsertedCount,
186
+ insertedIds: idsAsText(result.insertedIds),
187
+ upsertedIds: idsAsText(result.upsertedIds),
188
+ };
189
+ })
190
+ .build();
191
+
192
+ export type { BulkInput, BulkOutput };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Procedure: mongo.documents.distinct (roadmap R44)
3
+ * The distinct values of one field in the documents that match a filter
4
+ */
5
+
6
+ import { createProcedure, zodAdapter, type Effects, type Procedure, type ProcedureContext } from "@procedures/client";
7
+ import { collectionFor, type ScopeInput } from "../scope.js";
8
+ import { scopeFields, documentObject } from "./input-schemas.js";
9
+ import { z } from "zod";
10
+ import { type DocumentQuery } from "../types.js";
11
+
12
+ // Input/Output types
13
+ interface DistinctInput extends ScopeInput {
14
+ /** The field, with dots for an embedded field ("address.city") */
15
+ field: string;
16
+ /** The filter of the documents (default: all documents) */
17
+ query?: DocumentQuery | undefined;
18
+ }
19
+
20
+ interface DistinctOutput {
21
+ values: unknown[];
22
+ }
23
+
24
+ // Schemas
25
+ const distinctInputSchema = zodAdapter<DistinctInput>(
26
+ z.object({
27
+ ...scopeFields(),
28
+ field: z.string().min(1).describe('The field, with dots for an embedded field (for example "address.city")'),
29
+ query: documentObject().optional().describe("The filter of the documents (default: all documents)"),
30
+ })
31
+ );
32
+ const distinctOutputSchema = zodAdapter<DistinctOutput>(
33
+ z.looseObject({
34
+ values: z.array(z.unknown()).describe("The distinct values of the field. An array field gives each of its elements."),
35
+ })
36
+ );
37
+
38
+ export const distinctProcedure: Procedure<
39
+ DistinctInput,
40
+ DistinctOutput,
41
+ { description: string; effects?: Effects }
42
+ > = createProcedure()
43
+ .path(["mongo", "documents", "distinct"])
44
+ .input(distinctInputSchema)
45
+ .output(distinctOutputSchema)
46
+ .meta({
47
+ description: "The distinct values of a field in the documents that match a filter",
48
+ effects: { reads: ["mongo", "network"], writes: [] },
49
+ })
50
+ .handler(async (input: DistinctInput, ctx: ProcedureContext) => {
51
+ const { collection } = await collectionFor(input, ctx);
52
+ const values = await collection.distinct(input.field, input.query ?? {});
53
+ return { values };
54
+ })
55
+ .build();
56
+
57
+ export type { DistinctInput, DistinctOutput };
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Procedure: mongo.documents.explain (roadmap R44)
3
+ * The plan of a find or of an aggregation pipeline
4
+ *
5
+ * With `pipeline`, the procedure explains an aggregation. Else it explains a find with `query`
6
+ * (default: all documents). It refuses `$out` and `$merge`, as `documents.aggregate` does (R7):
7
+ * the verbosity `executionStats` runs the plan.
8
+ */
9
+
10
+ import { createProcedure, zodAdapter, type Effects, type Procedure, type ProcedureContext } from "@procedures/client";
11
+ import type { Document, ExplainVerbosityLike, FindOptions } from "mongodb";
12
+ import { z } from "zod";
13
+ import { collectionFor, copyScope, SCOPE_FIELDS, type ScopeInput } from "../scope.js";
14
+ import { described, documentObject, scopeFields } from "./input-schemas.js";
15
+ import { writingStage } from "./documents.aggregate.js";
16
+ import type { AggregationStage, DocumentQuery, SortSpec } from "../types.js";
17
+
18
+ /** How much the server tells: the plan only, or also the statistics of a run. */
19
+ type Verbosity = "queryPlanner" | "executionStats" | "allPlansExecution";
20
+ const VERBOSITIES: readonly Verbosity[] = ["queryPlanner", "executionStats", "allPlansExecution"];
21
+
22
+ // Input/Output types
23
+ interface ExplainInput extends ScopeInput {
24
+ /** The filter of a find (default: all documents). Not with `pipeline`. */
25
+ query?: DocumentQuery;
26
+ /** The fields of a find to give (1) or to leave out (0) */
27
+ projection?: Record<string, 0 | 1>;
28
+ /** The sort order of a find */
29
+ sort?: SortSpec;
30
+ /** The most documents of a find */
31
+ limit?: number;
32
+ /** An aggregation pipeline (not with `query`) */
33
+ pipeline?: AggregationStage[];
34
+ /** The verbosity (default: queryPlanner) */
35
+ verbosity?: Verbosity;
36
+ }
37
+
38
+ interface ExplainOutput {
39
+ /** "find" or "aggregate" */
40
+ operation: "find" | "aggregate";
41
+ /** The explain document of the server */
42
+ plan: Record<string, unknown>;
43
+ }
44
+
45
+ const PROCEDURE = "mongo.documents.explain";
46
+ const INPUT_FIELDS = new Set<string>(["query", "projection", "sort", "limit", "pipeline", "verbosity", ...SCOPE_FIELDS]);
47
+
48
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
49
+ return typeof value === "object" && value !== null && !Array.isArray(value);
50
+ }
51
+
52
+ /** This function checks the input of an explain. */
53
+ export function parseExplainInput(data: unknown): ExplainInput {
54
+ if (!isPlainObject(data)) throw new Error(`${PROCEDURE}: input must be an object`);
55
+ for (const key of Object.keys(data)) {
56
+ if (!INPUT_FIELDS.has(key)) throw new Error(`${PROCEDURE}: unknown field "${key}". The fields are: ${[...INPUT_FIELDS].join(", ")}`);
57
+ }
58
+ const result: ExplainInput = {};
59
+ copyScope(data, result);
60
+ const pipeline = data["pipeline"];
61
+ if (pipeline !== undefined) {
62
+ for (const field of ["query", "projection", "sort", "limit"]) {
63
+ if (data[field] !== undefined) throw new Error(`${PROCEDURE}: give a pipeline or the fields of a find (${field}), not both`);
64
+ }
65
+ if (!Array.isArray(pipeline) || !pipeline.every(isPlainObject)) throw new Error(`${PROCEDURE}: pipeline must be a list of stages`);
66
+ const stage = writingStage(pipeline);
67
+ if (stage) throw new Error(`${PROCEDURE} is read only: the stage ${stage} writes to a collection`);
68
+ result.pipeline = pipeline as AggregationStage[];
69
+ }
70
+ for (const field of ["query", "projection", "sort"] as const) {
71
+ const value = data[field];
72
+ if (value === undefined) continue;
73
+ if (!isPlainObject(value)) throw new Error(`${PROCEDURE}: ${field} must be an object`);
74
+ (result as Record<string, unknown>)[field] = value;
75
+ }
76
+ const limit = data["limit"];
77
+ if (limit !== undefined) {
78
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1) throw new Error(`${PROCEDURE}: limit must be an integer >= 1`);
79
+ result.limit = limit;
80
+ }
81
+ const verbosity = data["verbosity"];
82
+ if (verbosity !== undefined) {
83
+ if (!VERBOSITIES.includes(verbosity as Verbosity)) throw new Error(`${PROCEDURE}: verbosity must be ${VERBOSITIES.join(", ")}`);
84
+ result.verbosity = verbosity as Verbosity;
85
+ }
86
+ return result;
87
+ }
88
+
89
+ /** The explain document without the fields of the cluster time: they hold signatures, not the plan. */
90
+ function planOf(explained: Document): Record<string, unknown> {
91
+ const { $clusterTime: _clusterTime, operationTime: _operationTime, ...plan } = explained;
92
+ return plan;
93
+ }
94
+
95
+ // Schemas
96
+ const explainInputSchema = described<ExplainInput>(
97
+ parseExplainInput,
98
+ z.object({
99
+ ...scopeFields(),
100
+ query: documentObject().optional().describe("The filter of a find (default: all documents). Not with pipeline."),
101
+ projection: z.record(z.string(), z.union([z.literal(0), z.literal(1)])).optional().describe("The fields of a find to give (1) or to leave out (0)"),
102
+ sort: z.record(z.string(), z.unknown()).optional().describe("The sort order of a find: { field: 1 } ascending, { field: -1 } descending"),
103
+ limit: z.number().int().min(1).optional().describe("The most documents of a find"),
104
+ pipeline: z.array(documentObject()).optional().describe("An aggregation pipeline to explain, in place of a find. $out and $merge are refused."),
105
+ verbosity: z
106
+ .enum(["queryPlanner", "executionStats", "allPlansExecution"])
107
+ .optional()
108
+ .describe("queryPlanner (the default): the plan only. executionStats and allPlansExecution also run the plan."),
109
+ })
110
+ );
111
+ const explainOutputSchema = zodAdapter<ExplainOutput>(
112
+ z.looseObject({
113
+ operation: z.enum(["find", "aggregate"]).describe("The operation that the server explained"),
114
+ plan: z.record(z.string(), z.unknown()).describe("The explain document of the server (queryPlanner, and executionStats with that verbosity)"),
115
+ })
116
+ );
117
+
118
+ export const explainProcedure: Procedure<
119
+ ExplainInput,
120
+ ExplainOutput,
121
+ { description: string; effects?: Effects }
122
+ > = createProcedure()
123
+ .path(["mongo", "documents", "explain"])
124
+ .input(explainInputSchema)
125
+ .output(explainOutputSchema)
126
+ .meta({
127
+ description: "The plan of a find or an aggregation pipeline (read only: $out and $merge are refused)",
128
+ effects: { reads: ["mongo", "network"], writes: [] },
129
+ })
130
+ .handler(async (input: ExplainInput, ctx: ProcedureContext): Promise<ExplainOutput> => {
131
+ const { collection } = await collectionFor(input, ctx);
132
+ const verbosity: ExplainVerbosityLike = input.verbosity ?? "queryPlanner";
133
+ if (input.pipeline) {
134
+ return { operation: "aggregate", plan: planOf(await collection.aggregate(input.pipeline).explain(verbosity)) };
135
+ }
136
+ const options: FindOptions = {};
137
+ if (input.projection) options.projection = input.projection;
138
+ if (input.sort) options.sort = input.sort;
139
+ if (input.limit !== undefined) options.limit = input.limit;
140
+ return { operation: "find", plan: planOf(await collection.find(input.query ?? {}, options).explain(verbosity)) };
141
+ })
142
+ .build();
143
+
144
+ export type { ExplainInput, ExplainOutput };
@@ -20,6 +20,12 @@ export { updateProcedure } from "./documents.update.js";
20
20
  export { deleteProcedure } from "./documents.delete.js";
21
21
  export { countProcedure } from "./documents.count.js";
22
22
  export { aggregateProcedure } from "./documents.aggregate.js";
23
+ export { distinctProcedure } from "./documents.distinct.js";
24
+ export { bulkProcedure } from "./documents.bulk.js";
25
+ export { explainProcedure } from "./documents.explain.js";
26
+
27
+ // Change streams
28
+ export { watchProcedure } from "./watch.js";
23
29
 
24
30
  // Index procedures
25
31
  export { listIndexesProcedure } from "./indexes.list.js";
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Procedure: mongo.watch (roadmap R44)
3
+ * The changes of a collection or of a database, as a stream (a change stream)
4
+ *
5
+ * The stream has no end (`metadata.stream: { unbounded: true }`). It stops when the reader stops
6
+ * or the signal of the call aborts: the procedure then closes the change stream. A change stream
7
+ * needs a replica set or a sharded cluster: a server without one refuses it.
8
+ */
9
+
10
+ import { createProcedure, zodAdapter, type Effects, type Procedure, type ProcedureContext } from "@procedures/client";
11
+ import type { ChangeStream, ChangeStreamOptions, Document } from "mongodb";
12
+ import { z } from "zod";
13
+ import { collectionFor, databaseFor, copyScope, SCOPE_FIELDS, type ScopeInput } from "../scope.js";
14
+ import { described, documentObject } from "./input-schemas.js";
15
+
16
+ /** The stages that a change stream accepts after its own stage. */
17
+ const CHANGE_STREAM_STAGES = ["$match", "$project", "$addFields", "$set", "$unset", "$replaceRoot", "$replaceWith", "$redact"];
18
+
19
+ type FullDocument = "default" | "updateLookup" | "whenAvailable" | "required";
20
+ const FULL_DOCUMENT: readonly FullDocument[] = ["default", "updateLookup", "whenAvailable", "required"];
21
+
22
+ // Input/Output types
23
+ interface WatchInput extends ScopeInput {
24
+ /** The stages after the change stream stage: $match, $project and the other stages it accepts */
25
+ pipeline?: Document[];
26
+ /** The full document of an update: "default" (none), "updateLookup", "whenAvailable" or "required" */
27
+ fullDocument?: FullDocument;
28
+ /** The resume token of an earlier change: the stream starts after it */
29
+ resumeAfter?: string;
30
+ }
31
+
32
+ /** One change: an item of the stream. */
33
+ interface WatchChange {
34
+ /** insert, update, replace, delete, drop, rename, dropDatabase or invalidate */
35
+ operationType: string;
36
+ /** The database and the collection of the change */
37
+ ns?: { db: string; coll?: string | undefined } | undefined;
38
+ /** The _id of the changed document */
39
+ documentKey?: Record<string, unknown> | undefined;
40
+ /** The document of an insert or a replace (and of an update with fullDocument) */
41
+ fullDocument?: Record<string, unknown> | null | undefined;
42
+ /** The changed and removed fields of an update */
43
+ updateDescription?: Record<string, unknown> | undefined;
44
+ /** The time of the change on the server (ISO 8601), from MongoDB 6.0 */
45
+ wallTime?: string | undefined;
46
+ /** The resume token of the change: resumeAfter takes it */
47
+ resumeToken: string;
48
+ }
49
+
50
+ const PROCEDURE = "mongo.watch";
51
+ const INPUT_FIELDS = new Set<string>(["pipeline", "fullDocument", "resumeAfter", ...SCOPE_FIELDS]);
52
+
53
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
54
+ return typeof value === "object" && value !== null && !Array.isArray(value);
55
+ }
56
+
57
+ /** This function checks the input of a watch. */
58
+ export function parseWatchInput(data: unknown): WatchInput {
59
+ if (!isPlainObject(data)) throw new Error(`${PROCEDURE}: input must be an object`);
60
+ for (const key of Object.keys(data)) {
61
+ if (!INPUT_FIELDS.has(key)) throw new Error(`${PROCEDURE}: unknown field "${key}". The fields are: ${[...INPUT_FIELDS].join(", ")}`);
62
+ }
63
+ const result: WatchInput = {};
64
+ copyScope(data, result);
65
+ const pipeline = data["pipeline"];
66
+ if (pipeline !== undefined) {
67
+ if (!Array.isArray(pipeline)) throw new Error(`${PROCEDURE}: pipeline must be a list of stages`);
68
+ for (const [index, stage] of pipeline.entries()) {
69
+ const names = isPlainObject(stage) ? Object.keys(stage) : [];
70
+ if (names.length !== 1 || !CHANGE_STREAM_STAGES.includes(names[0]!)) {
71
+ throw new Error(`${PROCEDURE}: pipeline[${index}] must be one stage of ${CHANGE_STREAM_STAGES.join(", ")}`);
72
+ }
73
+ }
74
+ result.pipeline = pipeline as Document[];
75
+ }
76
+ const fullDocument = data["fullDocument"];
77
+ if (fullDocument !== undefined) {
78
+ if (!FULL_DOCUMENT.includes(fullDocument as FullDocument)) throw new Error(`${PROCEDURE}: fullDocument must be ${FULL_DOCUMENT.join(", ")}`);
79
+ result.fullDocument = fullDocument as FullDocument;
80
+ }
81
+ const resumeAfter = data["resumeAfter"];
82
+ if (resumeAfter !== undefined) {
83
+ if (typeof resumeAfter !== "string" || resumeAfter.length === 0) throw new Error(`${PROCEDURE}: resumeAfter must be a resume token (text)`);
84
+ result.resumeAfter = resumeAfter;
85
+ }
86
+ return result;
87
+ }
88
+
89
+ /** The item of one change event of the driver. */
90
+ export function toWatchChange(event: Document): WatchChange {
91
+ const id = event["_id"] as { _data?: unknown } | undefined;
92
+ const change: WatchChange = {
93
+ operationType: String(event["operationType"]),
94
+ resumeToken: typeof id?._data === "string" ? id._data : JSON.stringify(id ?? null),
95
+ };
96
+ if (isPlainObject(event["ns"])) change.ns = event["ns"] as { db: string; coll?: string | undefined };
97
+ if (isPlainObject(event["documentKey"])) change.documentKey = event["documentKey"];
98
+ if (event["fullDocument"] !== undefined) change.fullDocument = event["fullDocument"] as Record<string, unknown> | null;
99
+ if (isPlainObject(event["updateDescription"])) change.updateDescription = event["updateDescription"];
100
+ if (event["wallTime"] instanceof Date) change.wallTime = event["wallTime"].toISOString();
101
+ return change;
102
+ }
103
+
104
+ /**
105
+ * The changes, as a stream. The change stream closes when the reader stops (the `finally` of the
106
+ * generator) or when the signal aborts (the close ends the wait for the next change).
107
+ */
108
+ export async function* watchChanges(input: WatchInput, ctx: ProcedureContext): AsyncGenerator<WatchChange, void, undefined> {
109
+ const options: ChangeStreamOptions = {};
110
+ if (input.fullDocument) options.fullDocument = input.fullDocument;
111
+ if (input.resumeAfter) options.resumeAfter = { _data: input.resumeAfter };
112
+ const pipeline = input.pipeline ?? [];
113
+ const stream: ChangeStream =
114
+ input.collection !== undefined || ctx.metadata["collection"] !== undefined
115
+ ? (await collectionFor(input, ctx)).collection.watch(pipeline, options)
116
+ : (await databaseFor(input, ctx)).watch(pipeline, options);
117
+ const signal = ctx.signal;
118
+ // True when the signal closed the change stream. The driver also closes it before it throws a
119
+ // failure (a server without a replica set): that error goes to the reader.
120
+ let stopped = false;
121
+ const close = (): void => {
122
+ stopped = true;
123
+ stream.close().catch(() => undefined);
124
+ };
125
+ signal?.addEventListener("abort", close, { once: true });
126
+ try {
127
+ if (signal?.aborted) return;
128
+ for await (const event of stream) {
129
+ yield toWatchChange(event);
130
+ }
131
+ } catch (error) {
132
+ // The abort of the call ends the stream without an error
133
+ if (stopped) return;
134
+ throw error;
135
+ } finally {
136
+ signal?.removeEventListener("abort", close);
137
+ await stream.close().catch(() => undefined);
138
+ }
139
+ }
140
+
141
+ // Schemas
142
+ const watchInputSchema = described<WatchInput>(
143
+ parseWatchInput,
144
+ z.object({
145
+ collection: z.string().min(1).optional().describe("The collection to watch. Without it, the stream gives the changes of the database."),
146
+ database: z.string().min(1).optional().describe("The database name (default: the database of the connection)"),
147
+ pipeline: z.array(documentObject()).optional().describe("Stages after the change stream stage: $match, $project, $addFields, $set, $unset, $replaceRoot, $replaceWith, $redact"),
148
+ fullDocument: z.enum(["default", "updateLookup", "whenAvailable", "required"]).optional().describe("The full document of an update: updateLookup reads it (default: none)"),
149
+ resumeAfter: z.string().min(1).optional().describe("The resume token of an earlier change: the stream starts after it"),
150
+ })
151
+ );
152
+ const watchOutputSchema = zodAdapter<WatchChange>(
153
+ z.looseObject({
154
+ operationType: z.string().describe("insert, update, replace, delete, drop, rename, dropDatabase or invalidate"),
155
+ ns: z.looseObject({ db: z.string(), coll: z.string().optional() }).optional().describe("The database and the collection of the change"),
156
+ documentKey: z.record(z.string(), z.unknown()).optional().describe("The _id of the changed document"),
157
+ fullDocument: z.record(z.string(), z.unknown()).nullable().optional().describe("The document of an insert or a replace, and of an update with fullDocument"),
158
+ updateDescription: z.record(z.string(), z.unknown()).optional().describe("The changed and removed fields of an update"),
159
+ wallTime: z.string().optional().describe("The time of the change on the server (ISO 8601), from MongoDB 6.0"),
160
+ resumeToken: z.string().describe("The resume token of the change: resumeAfter takes it"),
161
+ })
162
+ );
163
+
164
+ export const watchProcedure: Procedure<
165
+ WatchInput,
166
+ WatchChange,
167
+ { description: string; effects?: Effects; stream?: { unbounded?: boolean }; output?: string }
168
+ > = createProcedure()
169
+ .path(["mongo", "watch"])
170
+ .input(watchInputSchema)
171
+ .output(watchOutputSchema)
172
+ .meta({
173
+ description: "The changes of a collection or a database, as a stream without an end (a change stream needs a replica set)",
174
+ effects: { reads: ["mongo", "network"], writes: [] },
175
+ // The stream has no end: it stops when the reader stops (roadmap R15)
176
+ stream: { unbounded: true },
177
+ output: "streaming",
178
+ })
179
+ // invokeProcedure reads a returned async iterable as a stream: the handler types of the core do
180
+ // not describe that case
181
+ .handler(((input: WatchInput, ctx: ProcedureContext) => watchChanges(input, ctx)) as never)
182
+ .build();
183
+
184
+ export type { WatchInput, WatchChange };
@@ -43,6 +43,10 @@ import type { UpdateInput, UpdateOutput } from "./procedures/documents.update.js
43
43
  import type { DeleteInput, DeleteOutput } from "./procedures/documents.delete.js";
44
44
  import type { CountInput, CountOutput } from "./procedures/documents.count.js";
45
45
  import type { AggregateInput, AggregateOutput } from "./procedures/documents.aggregate.js";
46
+ import type { DistinctInput, DistinctOutput } from "./procedures/documents.distinct.js";
47
+ import type { BulkInput, BulkOutput } from "./procedures/documents.bulk.js";
48
+ import type { ExplainInput, ExplainOutput } from "./procedures/documents.explain.js";
49
+ import type { WatchInput, WatchChange } from "./procedures/watch.js";
46
50
  import type { ListIndexesInput, ListIndexesOutput } from "./procedures/indexes.list.js";
47
51
  import type { CreateIndexInput, CreateIndexOutput } from "./procedures/indexes.create.js";
48
52
  import type { DropIndexInput, DropIndexOutput } from "./procedures/indexes.drop.js";
@@ -72,6 +76,14 @@ export type {
72
76
  CountOutput,
73
77
  AggregateInput,
74
78
  AggregateOutput,
79
+ DistinctInput,
80
+ DistinctOutput,
81
+ BulkInput,
82
+ BulkOutput,
83
+ ExplainInput,
84
+ ExplainOutput,
85
+ WatchInput,
86
+ WatchChange,
75
87
  ListIndexesInput,
76
88
  ListIndexesOutput,
77
89
  CreateIndexInput,
@@ -108,12 +120,17 @@ export interface MongoProcedures {
108
120
  delete: { input: DeleteInput; output: DeleteOutput };
109
121
  count: { input: CountInput; output: CountOutput };
110
122
  aggregate: { input: AggregateInput; output: AggregateOutput };
123
+ distinct: { input: DistinctInput; output: DistinctOutput };
124
+ bulk: { input: BulkInput; output: BulkOutput };
125
+ explain: { input: ExplainInput; output: ExplainOutput };
111
126
  };
112
127
  indexes: {
113
128
  list: { input: ListIndexesInput; output: ListIndexesOutput };
114
129
  create: { input: CreateIndexInput; output: CreateIndexOutput };
115
130
  drop: { input: DropIndexInput; output: DropIndexOutput };
116
131
  };
132
+ /** A stream: the output is the type of one item */
133
+ watch: { input: WatchInput; output: WatchChange };
117
134
  };
118
135
  }
119
136
 
@@ -134,9 +151,13 @@ export type ProcedureInput<P extends readonly string[]> =
134
151
  P extends ["mongo", "documents", "delete"] ? DeleteInput :
135
152
  P extends ["mongo", "documents", "count"] ? CountInput :
136
153
  P extends ["mongo", "documents", "aggregate"] ? AggregateInput :
154
+ P extends ["mongo", "documents", "distinct"] ? DistinctInput :
155
+ P extends ["mongo", "documents", "bulk"] ? BulkInput :
156
+ P extends ["mongo", "documents", "explain"] ? ExplainInput :
137
157
  P extends ["mongo", "indexes", "list"] ? ListIndexesInput :
138
158
  P extends ["mongo", "indexes", "create"] ? CreateIndexInput :
139
159
  P extends ["mongo", "indexes", "drop"] ? DropIndexInput :
160
+ P extends ["mongo", "watch"] ? WatchInput :
140
161
  never;
141
162
 
142
163
  /**
@@ -156,7 +177,11 @@ export type ProcedureOutput<P extends readonly string[]> =
156
177
  P extends ["mongo", "documents", "delete"] ? DeleteOutput :
157
178
  P extends ["mongo", "documents", "count"] ? CountOutput :
158
179
  P extends ["mongo", "documents", "aggregate"] ? AggregateOutput :
180
+ P extends ["mongo", "documents", "distinct"] ? DistinctOutput :
181
+ P extends ["mongo", "documents", "bulk"] ? BulkOutput :
182
+ P extends ["mongo", "documents", "explain"] ? ExplainOutput :
159
183
  P extends ["mongo", "indexes", "list"] ? ListIndexesOutput :
160
184
  P extends ["mongo", "indexes", "create"] ? CreateIndexOutput :
161
185
  P extends ["mongo", "indexes", "drop"] ? DropIndexOutput :
186
+ P extends ["mongo", "watch"] ? WatchChange :
162
187
  never;