@fadhilp/stateql 0.5.4 → 0.6.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 +64 -21
- package/dist/src/adapters.d.ts +1 -0
- package/dist/src/adapters.js +19 -6
- package/dist/src/cli.js +48 -2
- package/dist/src/connection.d.ts +1 -0
- package/dist/src/connection.js +29 -3
- package/dist/src/index.d.ts +1 -1
- package/dist/src/migrations.js +10 -0
- package/dist/src/mongodb.d.ts +48 -0
- package/dist/src/mongodb.js +868 -0
- package/dist/src/response-data.js +12 -0
- package/dist/src/sql.d.ts +3 -1
- package/dist/src/sql.js +3 -0
- package/dist/src/stateql.d.ts +8 -1
- package/dist/src/stateql.js +544 -22
- package/dist/src/store.d.ts +5 -2
- package/dist/src/store.js +13 -7
- package/dist/src/types.d.ts +90 -2
- package/package.json +3 -2
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
import { BSON, MongoClient, } from "mongodb";
|
|
2
|
+
import { AdapterExecutionError, AdapterWriteError, BatchWriteError, } from "./adapters.js";
|
|
3
|
+
const MAX_INSPECTION_ITEMS = 1_000;
|
|
4
|
+
const FIND_OPTIONS = new Set([
|
|
5
|
+
"projection",
|
|
6
|
+
"sort",
|
|
7
|
+
"skip",
|
|
8
|
+
"limit",
|
|
9
|
+
"hint",
|
|
10
|
+
"collation",
|
|
11
|
+
]);
|
|
12
|
+
const AGGREGATE_OPTIONS = new Set(["allowDiskUse", "hint", "collation"]);
|
|
13
|
+
const WRITE_OPTIONS = {
|
|
14
|
+
insertOne: new Set(),
|
|
15
|
+
insertMany: new Set(["ordered"]),
|
|
16
|
+
updateOne: new Set(["upsert", "collation", "hint"]),
|
|
17
|
+
updateMany: new Set(["upsert", "collation", "hint"]),
|
|
18
|
+
replaceOne: new Set(["upsert", "collation", "hint"]),
|
|
19
|
+
deleteOne: new Set(["collation", "hint"]),
|
|
20
|
+
deleteMany: new Set(["collation", "hint"]),
|
|
21
|
+
};
|
|
22
|
+
const FORBIDDEN_KEYS = new Set([
|
|
23
|
+
"$out",
|
|
24
|
+
"$merge",
|
|
25
|
+
"$changeStream",
|
|
26
|
+
"$where",
|
|
27
|
+
"$function",
|
|
28
|
+
"$accumulator",
|
|
29
|
+
]);
|
|
30
|
+
const UPDATE_PIPELINE_STAGES = new Set([
|
|
31
|
+
"$addFields",
|
|
32
|
+
"$set",
|
|
33
|
+
"$project",
|
|
34
|
+
"$unset",
|
|
35
|
+
"$replaceRoot",
|
|
36
|
+
"$replaceWith",
|
|
37
|
+
]);
|
|
38
|
+
const READ_KEYS = {
|
|
39
|
+
find: new Set(["operation", "collection", "filter", "options"]),
|
|
40
|
+
aggregate: new Set(["operation", "collection", "pipeline", "options"]),
|
|
41
|
+
};
|
|
42
|
+
const WRITE_KEYS = {
|
|
43
|
+
insertOne: new Set(["operation", "collection", "document", "options"]),
|
|
44
|
+
insertMany: new Set(["operation", "collection", "documents", "options"]),
|
|
45
|
+
updateOne: new Set(["operation", "collection", "filter", "update", "options"]),
|
|
46
|
+
updateMany: new Set(["operation", "collection", "filter", "update", "options"]),
|
|
47
|
+
replaceOne: new Set(["operation", "collection", "filter", "replacement", "options"]),
|
|
48
|
+
deleteOne: new Set(["operation", "collection", "filter", "options"]),
|
|
49
|
+
deleteMany: new Set(["operation", "collection", "filter", "options"]),
|
|
50
|
+
};
|
|
51
|
+
export function validateMongoReadCommand(value) {
|
|
52
|
+
if (!isRecord(value))
|
|
53
|
+
throw new Error("MongoDB read command must be an object.");
|
|
54
|
+
const operation = value.operation;
|
|
55
|
+
if (operation !== "find" && operation !== "aggregate") {
|
|
56
|
+
throw new Error("MongoDB reads only support find and aggregate.");
|
|
57
|
+
}
|
|
58
|
+
rejectUnknownKeys(value, READ_KEYS[operation], "read command");
|
|
59
|
+
validateCollection(value.collection);
|
|
60
|
+
validateOptions(value.options, operation === "find" ? FIND_OPTIONS : AGGREGATE_OPTIONS, operation);
|
|
61
|
+
if (operation === "find") {
|
|
62
|
+
if (value.filter !== undefined && !isDocument(value.filter)) {
|
|
63
|
+
throw new Error("MongoDB find filter must be an object.");
|
|
64
|
+
}
|
|
65
|
+
validateFindOptions(value.options);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
if (!Array.isArray(value.pipeline) || !value.pipeline.every(isDocument)) {
|
|
69
|
+
throw new Error("MongoDB aggregate pipeline must be an array of stage objects.");
|
|
70
|
+
}
|
|
71
|
+
for (const stage of value.pipeline) {
|
|
72
|
+
if (Object.keys(stage).length !== 1) {
|
|
73
|
+
throw new Error("Each MongoDB aggregate pipeline stage must contain exactly one operator.");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
validateAggregateOptions(value.options);
|
|
77
|
+
}
|
|
78
|
+
rejectForbiddenValues(value);
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
export function validateMongoWriteCommand(value) {
|
|
82
|
+
if (!isRecord(value))
|
|
83
|
+
throw new Error("MongoDB write command must be an object.");
|
|
84
|
+
const operation = value.operation;
|
|
85
|
+
if (!isMongoWriteOperation(operation)) {
|
|
86
|
+
throw new Error(`Unsupported MongoDB write operation "${String(operation)}".`);
|
|
87
|
+
}
|
|
88
|
+
rejectUnknownKeys(value, WRITE_KEYS[operation], "write command");
|
|
89
|
+
validateCollection(value.collection);
|
|
90
|
+
validateOptions(value.options, WRITE_OPTIONS[operation], operation);
|
|
91
|
+
validateWriteOptions(value.options, operation);
|
|
92
|
+
switch (operation) {
|
|
93
|
+
case "insertOne":
|
|
94
|
+
requireDocument(value.document, "insertOne document");
|
|
95
|
+
break;
|
|
96
|
+
case "insertMany":
|
|
97
|
+
if (!Array.isArray(value.documents) || value.documents.length === 0 || !value.documents.every(isDocument)) {
|
|
98
|
+
throw new Error("MongoDB insertMany documents must be a non-empty array of objects.");
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
case "updateOne":
|
|
102
|
+
case "updateMany":
|
|
103
|
+
requireDocument(value.filter, `${operation} filter`);
|
|
104
|
+
validateUpdate(value.update);
|
|
105
|
+
break;
|
|
106
|
+
case "replaceOne":
|
|
107
|
+
requireDocument(value.filter, "replaceOne filter");
|
|
108
|
+
requireDocument(value.replacement, "replaceOne replacement");
|
|
109
|
+
if (Object.keys(value.replacement).some((key) => key.startsWith("$"))) {
|
|
110
|
+
throw new Error("MongoDB replacement documents cannot contain top-level update operators.");
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
case "deleteOne":
|
|
114
|
+
case "deleteMany":
|
|
115
|
+
requireDocument(value.filter, `${operation} filter`);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
rejectForbiddenValues(value);
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
export function analyzeMongoWriteSafety(command) {
|
|
122
|
+
const value = validateMongoWriteCommand(command);
|
|
123
|
+
switch (value.operation) {
|
|
124
|
+
case "insertOne":
|
|
125
|
+
case "insertMany":
|
|
126
|
+
return { unbounded: false, destructive: false };
|
|
127
|
+
case "updateOne":
|
|
128
|
+
case "updateMany":
|
|
129
|
+
return {
|
|
130
|
+
unbounded: Object.keys(value.filter).length === 0,
|
|
131
|
+
destructive: false,
|
|
132
|
+
};
|
|
133
|
+
case "replaceOne":
|
|
134
|
+
return {
|
|
135
|
+
unbounded: Object.keys(value.filter).length === 0,
|
|
136
|
+
destructive: true,
|
|
137
|
+
};
|
|
138
|
+
case "deleteOne":
|
|
139
|
+
case "deleteMany":
|
|
140
|
+
return {
|
|
141
|
+
unbounded: Object.keys(value.filter).length === 0,
|
|
142
|
+
destructive: true,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Deterministic EJSON preserves BSON types and property order. */
|
|
147
|
+
export function serializeMongoCommand(command) {
|
|
148
|
+
return BSON.EJSON.stringify(command, { relaxed: false });
|
|
149
|
+
}
|
|
150
|
+
export function deserializeMongoWriteCommand(value) {
|
|
151
|
+
return validateMongoWriteCommand(parseEjson(value));
|
|
152
|
+
}
|
|
153
|
+
export class MongoAdapter {
|
|
154
|
+
context;
|
|
155
|
+
confidence = "ttl_based";
|
|
156
|
+
client;
|
|
157
|
+
databaseName;
|
|
158
|
+
readOnly;
|
|
159
|
+
connected = false;
|
|
160
|
+
closed = false;
|
|
161
|
+
connecting;
|
|
162
|
+
constructor(connection, context, input) {
|
|
163
|
+
this.context = context;
|
|
164
|
+
if (connection.driver !== "mongodb") {
|
|
165
|
+
throw new Error("MongoAdapter requires a MongoDB connection record.");
|
|
166
|
+
}
|
|
167
|
+
if (!connection.database_name || connection.database_name.includes("\0")) {
|
|
168
|
+
throw new Error("MongoDB connection requires an explicit valid database.");
|
|
169
|
+
}
|
|
170
|
+
this.databaseName = connection.database_name;
|
|
171
|
+
this.readOnly = Boolean(connection.read_only);
|
|
172
|
+
const timeout = remainingMilliseconds(context);
|
|
173
|
+
this.client = new MongoClient(input.source, {
|
|
174
|
+
connectTimeoutMS: timeout,
|
|
175
|
+
serverSelectionTimeoutMS: timeout,
|
|
176
|
+
waitQueueTimeoutMS: timeout,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
async ping() {
|
|
180
|
+
await this.connect();
|
|
181
|
+
const signal = operationSignal(this.context);
|
|
182
|
+
try {
|
|
183
|
+
await this.client.db(this.databaseName).command({ ping: 1 }, {
|
|
184
|
+
timeoutMS: remainingMilliseconds(this.context),
|
|
185
|
+
signal,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
throw readError(error, this.context);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async signature() {
|
|
193
|
+
throwIfStopped(this.context, false);
|
|
194
|
+
return "mongodb:ttl";
|
|
195
|
+
}
|
|
196
|
+
async read(command, maxRows) {
|
|
197
|
+
const value = validateMongoReadCommand(command);
|
|
198
|
+
if (!Number.isSafeInteger(maxRows) || maxRows <= 0) {
|
|
199
|
+
throw new Error("MongoDB maxRows must be a positive integer.");
|
|
200
|
+
}
|
|
201
|
+
await this.connect();
|
|
202
|
+
const collection = this.client.db(this.databaseName).collection(value.collection);
|
|
203
|
+
const signal = operationSignal(this.context);
|
|
204
|
+
let cursor;
|
|
205
|
+
try {
|
|
206
|
+
if (value.operation === "find") {
|
|
207
|
+
const options = findOptions(value.options, this.context, signal, maxRows);
|
|
208
|
+
cursor = collection.find(value.filter ?? {}, options);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
const options = aggregateOptions(value.options, this.context, signal);
|
|
212
|
+
cursor = collection.aggregate(value.pipeline, options).limit(maxRows);
|
|
213
|
+
}
|
|
214
|
+
const documents = await collectCursor(cursor, maxRows, this.context);
|
|
215
|
+
return documentsResult(documents);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
throw readError(error, this.context);
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
await cursor?.close().catch(() => undefined);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async write(command) {
|
|
225
|
+
let value;
|
|
226
|
+
try {
|
|
227
|
+
value = validateMongoWriteCommand(command);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
throw new AdapterWriteError(errorText(error), false);
|
|
231
|
+
}
|
|
232
|
+
if (this.readOnly)
|
|
233
|
+
throw new AdapterWriteError("Connection is read-only.", false);
|
|
234
|
+
throwIfStopped(this.context, false);
|
|
235
|
+
try {
|
|
236
|
+
await this.connect();
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (error instanceof AdapterExecutionError)
|
|
240
|
+
throw error;
|
|
241
|
+
throw new AdapterWriteError(errorText(error), false);
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
return await withContext(this.executeWrite(value), this.context, () => this.stop(), true);
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
const stopped = writeStoppedError(error, this.context, true);
|
|
248
|
+
if (stopped)
|
|
249
|
+
throw stopped;
|
|
250
|
+
throw new AdapterWriteError(errorText(error), knownNoWrite(error) ? false : true);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async writeBatch(commands, isolation) {
|
|
254
|
+
if (isolation.toLowerCase() !== "snapshot") {
|
|
255
|
+
throw new BatchWriteError(`Unsupported MongoDB isolation level "${isolation}".`, false);
|
|
256
|
+
}
|
|
257
|
+
let values;
|
|
258
|
+
try {
|
|
259
|
+
if (!Array.isArray(commands))
|
|
260
|
+
throw new Error("MongoDB transaction commands must be an array.");
|
|
261
|
+
values = commands.map(validateMongoWriteCommand);
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
throw new BatchWriteError(errorText(error), false);
|
|
265
|
+
}
|
|
266
|
+
if (this.readOnly)
|
|
267
|
+
throw new BatchWriteError("Connection is read-only.", false);
|
|
268
|
+
throwIfStopped(this.context, false);
|
|
269
|
+
try {
|
|
270
|
+
await this.connect();
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (error instanceof AdapterExecutionError)
|
|
274
|
+
throw error;
|
|
275
|
+
throw new BatchWriteError(errorText(error), false);
|
|
276
|
+
}
|
|
277
|
+
const session = this.client.startSession();
|
|
278
|
+
let dispatched = false;
|
|
279
|
+
let committing = false;
|
|
280
|
+
try {
|
|
281
|
+
session.startTransaction({
|
|
282
|
+
readConcern: { level: "snapshot" },
|
|
283
|
+
writeConcern: { w: "majority" },
|
|
284
|
+
readPreference: "primary",
|
|
285
|
+
maxCommitTimeMS: remainingMilliseconds(this.context),
|
|
286
|
+
});
|
|
287
|
+
const results = [];
|
|
288
|
+
for (const command of values) {
|
|
289
|
+
throwIfStopped(this.context, dispatched);
|
|
290
|
+
dispatched = true;
|
|
291
|
+
results.push(await withContext(this.executeWrite(command, session), this.context, () => this.stop(), true));
|
|
292
|
+
}
|
|
293
|
+
throwIfStopped(this.context, dispatched);
|
|
294
|
+
committing = true;
|
|
295
|
+
await withContext(session.commitTransaction({ timeoutMS: remainingMilliseconds(this.context) }), this.context, () => this.stop(), true);
|
|
296
|
+
return results;
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
let aborted = false;
|
|
300
|
+
if (!committing && session.inTransaction()) {
|
|
301
|
+
try {
|
|
302
|
+
await session.abortTransaction({
|
|
303
|
+
timeoutMS: remainingMilliseconds(this.context),
|
|
304
|
+
});
|
|
305
|
+
aborted = true;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// Without a confirmed abort, the staged write outcome is unknown.
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const outcomeUnknown = committing || (dispatched && !aborted);
|
|
312
|
+
const stopped = writeStoppedError(error, this.context, outcomeUnknown);
|
|
313
|
+
if (stopped)
|
|
314
|
+
throw stopped;
|
|
315
|
+
throw new BatchWriteError(errorText(error), outcomeUnknown && !knownNoWrite(error));
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
await session.endSession().catch(() => undefined);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async inspect(kind, name) {
|
|
322
|
+
await this.connect();
|
|
323
|
+
try {
|
|
324
|
+
if (kind === "schema" || kind === "collections") {
|
|
325
|
+
const cursor = this.client.db(this.databaseName).listCollections({}, {
|
|
326
|
+
nameOnly: true,
|
|
327
|
+
signal: operationSignal(this.context),
|
|
328
|
+
maxTimeMS: remainingMilliseconds(this.context),
|
|
329
|
+
timeoutMS: remainingMilliseconds(this.context),
|
|
330
|
+
});
|
|
331
|
+
const collections = await collectCursor(cursor, MAX_INSPECTION_ITEMS, this.context);
|
|
332
|
+
return ejsonSafe({
|
|
333
|
+
collections: collections.map((collection) => ({
|
|
334
|
+
name: collection.name,
|
|
335
|
+
type: collection.type ?? "collection",
|
|
336
|
+
})),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
validateCollection(name);
|
|
340
|
+
const collectionName = name;
|
|
341
|
+
await this.requireCollection(collectionName);
|
|
342
|
+
if (kind === "constraints") {
|
|
343
|
+
return { collection: collectionName, constraints: [] };
|
|
344
|
+
}
|
|
345
|
+
const columns = await this.sampleColumns(collectionName);
|
|
346
|
+
if (kind === "columns")
|
|
347
|
+
return { collection: collectionName, columns };
|
|
348
|
+
const indexes = await this.collectionIndexes(collectionName);
|
|
349
|
+
if (kind === "indexes")
|
|
350
|
+
return { collection: collectionName, indexes };
|
|
351
|
+
if (kind !== "table" && kind !== "collection") {
|
|
352
|
+
throw new Error(`Unknown inspection kind "${kind}".`);
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
collection: collectionName,
|
|
356
|
+
columns,
|
|
357
|
+
indexes: indexes.length,
|
|
358
|
+
constraints: 0,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
throw readError(error, this.context);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
async close() {
|
|
366
|
+
if (this.closed)
|
|
367
|
+
return;
|
|
368
|
+
this.closed = true;
|
|
369
|
+
this.connected = false;
|
|
370
|
+
await this.client.close().catch(() => undefined);
|
|
371
|
+
}
|
|
372
|
+
async connect() {
|
|
373
|
+
throwIfStopped(this.context, false);
|
|
374
|
+
if (this.closed)
|
|
375
|
+
throw new Error("MongoDB adapter is closed.");
|
|
376
|
+
if (this.connected)
|
|
377
|
+
return;
|
|
378
|
+
if (!this.connecting) {
|
|
379
|
+
this.connecting = withContext(this.client.connect().then(() => undefined), this.context, () => this.stop(), false).then(() => {
|
|
380
|
+
this.connected = true;
|
|
381
|
+
}).finally(() => {
|
|
382
|
+
this.connecting = undefined;
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
await this.connecting;
|
|
386
|
+
}
|
|
387
|
+
async executeWrite(command, session) {
|
|
388
|
+
const collection = this.client.db(this.databaseName).collection(command.collection);
|
|
389
|
+
const timeoutMS = remainingMilliseconds(this.context);
|
|
390
|
+
switch (command.operation) {
|
|
391
|
+
case "insertOne": {
|
|
392
|
+
const result = await collection.insertOne(command.document, { session, timeoutMS });
|
|
393
|
+
return {
|
|
394
|
+
affectedRows: result.acknowledged ? 1 : 0,
|
|
395
|
+
outcome: {
|
|
396
|
+
acknowledged: result.acknowledged,
|
|
397
|
+
inserted_id: ejsonSafe(result.insertedId),
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
case "insertMany": {
|
|
402
|
+
const options = writeOptions(command.options, command.operation, session, timeoutMS);
|
|
403
|
+
const result = await collection.insertMany(command.documents, options);
|
|
404
|
+
const insertedIds = Object.entries(result.insertedIds)
|
|
405
|
+
.sort(([left], [right]) => Number(left) - Number(right))
|
|
406
|
+
.map(([, id]) => ejsonSafe(id));
|
|
407
|
+
return {
|
|
408
|
+
affectedRows: result.insertedCount,
|
|
409
|
+
outcome: {
|
|
410
|
+
acknowledged: result.acknowledged,
|
|
411
|
+
inserted_count: result.insertedCount,
|
|
412
|
+
inserted_ids: insertedIds,
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
case "updateOne":
|
|
417
|
+
case "updateMany": {
|
|
418
|
+
const options = writeOptions(command.options, command.operation, session, timeoutMS);
|
|
419
|
+
const result = command.operation === "updateOne"
|
|
420
|
+
? await collection.updateOne(command.filter, command.update, options)
|
|
421
|
+
: await collection.updateMany(command.filter, command.update, options);
|
|
422
|
+
return updateResult(result);
|
|
423
|
+
}
|
|
424
|
+
case "replaceOne": {
|
|
425
|
+
const options = writeOptions(command.options, command.operation, session, timeoutMS);
|
|
426
|
+
const result = await collection.replaceOne(command.filter, command.replacement, options);
|
|
427
|
+
return updateResult(result);
|
|
428
|
+
}
|
|
429
|
+
case "deleteOne":
|
|
430
|
+
case "deleteMany": {
|
|
431
|
+
const options = writeOptions(command.options, command.operation, session, timeoutMS);
|
|
432
|
+
const result = command.operation === "deleteOne"
|
|
433
|
+
? await collection.deleteOne(command.filter, options)
|
|
434
|
+
: await collection.deleteMany(command.filter, options);
|
|
435
|
+
return {
|
|
436
|
+
affectedRows: result.deletedCount,
|
|
437
|
+
outcome: {
|
|
438
|
+
acknowledged: result.acknowledged,
|
|
439
|
+
deleted_count: result.deletedCount,
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
async requireCollection(name) {
|
|
446
|
+
const cursor = this.client.db(this.databaseName).listCollections({ name }, {
|
|
447
|
+
nameOnly: true,
|
|
448
|
+
signal: operationSignal(this.context),
|
|
449
|
+
maxTimeMS: remainingMilliseconds(this.context),
|
|
450
|
+
timeoutMS: remainingMilliseconds(this.context),
|
|
451
|
+
});
|
|
452
|
+
const found = await collectCursor(cursor, 1, this.context);
|
|
453
|
+
if (found.length === 0)
|
|
454
|
+
throw new Error(`Collection "${name}" was not found.`);
|
|
455
|
+
}
|
|
456
|
+
async sampleColumns(name) {
|
|
457
|
+
const cursor = this.client.db(this.databaseName).collection(name).find({}, {
|
|
458
|
+
limit: 1,
|
|
459
|
+
signal: operationSignal(this.context),
|
|
460
|
+
maxTimeMS: remainingMilliseconds(this.context),
|
|
461
|
+
timeoutMS: remainingMilliseconds(this.context),
|
|
462
|
+
});
|
|
463
|
+
const documents = await collectCursor(cursor, 1, this.context);
|
|
464
|
+
return inferColumns(documents);
|
|
465
|
+
}
|
|
466
|
+
async collectionIndexes(name) {
|
|
467
|
+
const cursor = this.client.db(this.databaseName).collection(name).listIndexes({
|
|
468
|
+
maxTimeMS: remainingMilliseconds(this.context),
|
|
469
|
+
timeoutMS: remainingMilliseconds(this.context),
|
|
470
|
+
});
|
|
471
|
+
const indexes = await collectCursor(cursor, MAX_INSPECTION_ITEMS, this.context);
|
|
472
|
+
return ejsonSafe(indexes);
|
|
473
|
+
}
|
|
474
|
+
stop() {
|
|
475
|
+
if (this.closed)
|
|
476
|
+
return;
|
|
477
|
+
this.closed = true;
|
|
478
|
+
this.connected = false;
|
|
479
|
+
void this.client.close().catch(() => undefined);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function findOptions(options, context, signal, maxRows) {
|
|
483
|
+
const source = options ?? {};
|
|
484
|
+
const requestedLimit = source.limit === undefined ? 0 : numericOption(source.limit, "find limit");
|
|
485
|
+
const result = {
|
|
486
|
+
signal,
|
|
487
|
+
maxTimeMS: remainingMilliseconds(context),
|
|
488
|
+
timeoutMS: remainingMilliseconds(context),
|
|
489
|
+
limit: requestedLimit > 0 ? Math.min(requestedLimit, maxRows) : maxRows,
|
|
490
|
+
};
|
|
491
|
+
if (source.projection !== undefined)
|
|
492
|
+
result.projection = source.projection;
|
|
493
|
+
if (source.sort !== undefined)
|
|
494
|
+
result.sort = normalizeSort(source.sort);
|
|
495
|
+
if (source.skip !== undefined)
|
|
496
|
+
result.skip = numericOption(source.skip, "find skip");
|
|
497
|
+
if (source.hint !== undefined)
|
|
498
|
+
result.hint = source.hint;
|
|
499
|
+
if (source.collation !== undefined)
|
|
500
|
+
result.collation = source.collation;
|
|
501
|
+
return result;
|
|
502
|
+
}
|
|
503
|
+
function aggregateOptions(options, context, signal) {
|
|
504
|
+
const source = options ?? {};
|
|
505
|
+
const result = {
|
|
506
|
+
signal,
|
|
507
|
+
maxTimeMS: remainingMilliseconds(context),
|
|
508
|
+
timeoutMS: remainingMilliseconds(context),
|
|
509
|
+
};
|
|
510
|
+
if (source.allowDiskUse !== undefined)
|
|
511
|
+
result.allowDiskUse = source.allowDiskUse;
|
|
512
|
+
if (source.hint !== undefined)
|
|
513
|
+
result.hint = source.hint;
|
|
514
|
+
if (source.collation !== undefined)
|
|
515
|
+
result.collation = source.collation;
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
518
|
+
function writeOptions(options, operation, session, timeoutMS) {
|
|
519
|
+
const result = { session, timeoutMS };
|
|
520
|
+
for (const key of WRITE_OPTIONS[operation]) {
|
|
521
|
+
const value = options?.[key];
|
|
522
|
+
if (value !== undefined)
|
|
523
|
+
result[key] = value;
|
|
524
|
+
}
|
|
525
|
+
return result;
|
|
526
|
+
}
|
|
527
|
+
function updateResult(result) {
|
|
528
|
+
return {
|
|
529
|
+
affectedRows: result.modifiedCount + result.upsertedCount,
|
|
530
|
+
outcome: {
|
|
531
|
+
acknowledged: result.acknowledged,
|
|
532
|
+
matched_count: result.matchedCount,
|
|
533
|
+
modified_count: result.modifiedCount,
|
|
534
|
+
upserted_count: result.upsertedCount,
|
|
535
|
+
upserted_id: ejsonSafe(result.upsertedId),
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function documentsResult(documents) {
|
|
540
|
+
return {
|
|
541
|
+
rows: documents.map((document) => ejsonSafe(document)),
|
|
542
|
+
columns: inferColumns(documents),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
function inferColumns(documents) {
|
|
546
|
+
const types = new Map();
|
|
547
|
+
for (const document of documents) {
|
|
548
|
+
for (const [name, value] of Object.entries(document)) {
|
|
549
|
+
const type = mongoType(value);
|
|
550
|
+
const previous = types.get(name);
|
|
551
|
+
if (!previous || previous === "null")
|
|
552
|
+
types.set(name, type);
|
|
553
|
+
else if (type !== "null" && previous !== type)
|
|
554
|
+
types.set(name, "mixed");
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return [...types].map(([name, type]) => ({ name, type }));
|
|
558
|
+
}
|
|
559
|
+
function mongoType(value) {
|
|
560
|
+
if (value === null || value === undefined)
|
|
561
|
+
return "null";
|
|
562
|
+
if (Array.isArray(value))
|
|
563
|
+
return "array";
|
|
564
|
+
if (value instanceof Date)
|
|
565
|
+
return "date";
|
|
566
|
+
const bsonType = bsonTypeName(value);
|
|
567
|
+
if (bsonType)
|
|
568
|
+
return bsonType.replace(/^[A-Z]/, (letter) => letter.toLowerCase());
|
|
569
|
+
if (typeof value === "object")
|
|
570
|
+
return "object";
|
|
571
|
+
return typeof value;
|
|
572
|
+
}
|
|
573
|
+
async function collectCursor(cursor, limit, context) {
|
|
574
|
+
const values = [];
|
|
575
|
+
try {
|
|
576
|
+
while (values.length < limit) {
|
|
577
|
+
const value = await withContext(cursor.next(), context, () => void cursor.close().catch(() => undefined), false);
|
|
578
|
+
if (value === null)
|
|
579
|
+
break;
|
|
580
|
+
values.push(value);
|
|
581
|
+
}
|
|
582
|
+
return values;
|
|
583
|
+
}
|
|
584
|
+
finally {
|
|
585
|
+
await cursor.close().catch(() => undefined);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function validateFindOptions(value) {
|
|
589
|
+
if (value === undefined)
|
|
590
|
+
return;
|
|
591
|
+
const options = value;
|
|
592
|
+
if (options.limit !== undefined)
|
|
593
|
+
numericOption(options.limit, "find limit");
|
|
594
|
+
if (options.skip !== undefined)
|
|
595
|
+
numericOption(options.skip, "find skip");
|
|
596
|
+
if (options.projection !== undefined && !isDocument(options.projection)) {
|
|
597
|
+
throw new Error("MongoDB find projection must be an object.");
|
|
598
|
+
}
|
|
599
|
+
if (options.sort !== undefined && !isSort(options.sort)) {
|
|
600
|
+
throw new Error("MongoDB find sort must be an object or an array.");
|
|
601
|
+
}
|
|
602
|
+
validateHintAndCollation(options, "find");
|
|
603
|
+
}
|
|
604
|
+
function validateAggregateOptions(value) {
|
|
605
|
+
if (value === undefined)
|
|
606
|
+
return;
|
|
607
|
+
const options = value;
|
|
608
|
+
if (options.allowDiskUse !== undefined && typeof options.allowDiskUse !== "boolean") {
|
|
609
|
+
throw new Error("MongoDB aggregate allowDiskUse must be boolean.");
|
|
610
|
+
}
|
|
611
|
+
validateHintAndCollation(options, "aggregate");
|
|
612
|
+
}
|
|
613
|
+
function validateWriteOptions(value, operation) {
|
|
614
|
+
if (value === undefined)
|
|
615
|
+
return;
|
|
616
|
+
const options = value;
|
|
617
|
+
for (const key of ["upsert", "ordered"]) {
|
|
618
|
+
if (options[key] !== undefined && typeof options[key] !== "boolean") {
|
|
619
|
+
throw new Error(`MongoDB ${operation} ${key} must be boolean.`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
validateHintAndCollation(options, operation);
|
|
623
|
+
}
|
|
624
|
+
function validateHintAndCollation(options, operation) {
|
|
625
|
+
if (options.hint !== undefined &&
|
|
626
|
+
typeof options.hint !== "string" &&
|
|
627
|
+
!isDocument(options.hint)) {
|
|
628
|
+
throw new Error(`MongoDB ${operation} hint must be a string or object.`);
|
|
629
|
+
}
|
|
630
|
+
if (options.collation !== undefined && !isDocument(options.collation)) {
|
|
631
|
+
throw new Error(`MongoDB ${operation} collation must be an object.`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function validateUpdate(value) {
|
|
635
|
+
if (Array.isArray(value)) {
|
|
636
|
+
if (value.length === 0 || !value.every(isDocument)) {
|
|
637
|
+
throw new Error("MongoDB update pipeline must be a non-empty array of stage objects.");
|
|
638
|
+
}
|
|
639
|
+
for (const stage of value) {
|
|
640
|
+
const keys = Object.keys(stage);
|
|
641
|
+
if (keys.length !== 1 || !UPDATE_PIPELINE_STAGES.has(keys[0])) {
|
|
642
|
+
throw new Error("MongoDB update pipeline contains an unsupported stage.");
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
requireDocument(value, "update document");
|
|
648
|
+
const keys = Object.keys(value);
|
|
649
|
+
if (keys.length === 0 || keys.some((key) => !key.startsWith("$"))) {
|
|
650
|
+
throw new Error("MongoDB update documents must contain only top-level update operators.");
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
function validateOptions(value, allowed, operation) {
|
|
654
|
+
if (value === undefined)
|
|
655
|
+
return;
|
|
656
|
+
if (!isDocument(value))
|
|
657
|
+
throw new Error(`MongoDB ${operation} options must be an object.`);
|
|
658
|
+
rejectUnknownKeys(value, allowed, `${operation} options`);
|
|
659
|
+
}
|
|
660
|
+
function validateCollection(value) {
|
|
661
|
+
if (typeof value !== "string" ||
|
|
662
|
+
value.length === 0 ||
|
|
663
|
+
value.includes("\0") ||
|
|
664
|
+
value.includes("$") ||
|
|
665
|
+
value.startsWith("system.")) {
|
|
666
|
+
throw new Error("Invalid MongoDB collection name.");
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
function requireDocument(value, name) {
|
|
670
|
+
if (!isDocument(value))
|
|
671
|
+
throw new Error(`MongoDB ${name} must be an object.`);
|
|
672
|
+
}
|
|
673
|
+
function rejectUnknownKeys(value, allowed, name) {
|
|
674
|
+
const key = Object.keys(value).find((candidate) => !allowed.has(candidate));
|
|
675
|
+
if (key)
|
|
676
|
+
throw new Error(`Unknown MongoDB ${name} field "${key}".`);
|
|
677
|
+
}
|
|
678
|
+
function rejectForbiddenValues(value) {
|
|
679
|
+
const seen = new WeakSet();
|
|
680
|
+
const visit = (item) => {
|
|
681
|
+
if (typeof item === "function")
|
|
682
|
+
throw new Error("MongoDB server-side JavaScript is forbidden.");
|
|
683
|
+
if (!item || typeof item !== "object")
|
|
684
|
+
return;
|
|
685
|
+
const object = item;
|
|
686
|
+
const bsonType = bsonTypeName(item);
|
|
687
|
+
if (bsonType === "Code")
|
|
688
|
+
throw new Error("MongoDB server-side JavaScript is forbidden.");
|
|
689
|
+
if (bsonType || item instanceof Date || item instanceof RegExp || Buffer.isBuffer(item))
|
|
690
|
+
return;
|
|
691
|
+
if (seen.has(object))
|
|
692
|
+
throw new Error("MongoDB commands cannot contain circular values.");
|
|
693
|
+
seen.add(object);
|
|
694
|
+
if (item instanceof Map) {
|
|
695
|
+
for (const [key, child] of item) {
|
|
696
|
+
if (FORBIDDEN_KEYS.has(String(key)))
|
|
697
|
+
forbiddenOperator(String(key));
|
|
698
|
+
visit(child);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
else if (item instanceof Set || Array.isArray(item)) {
|
|
702
|
+
for (const child of item)
|
|
703
|
+
visit(child);
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
for (const [key, child] of Object.entries(item)) {
|
|
707
|
+
if (FORBIDDEN_KEYS.has(key))
|
|
708
|
+
forbiddenOperator(key);
|
|
709
|
+
visit(child);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
seen.delete(object);
|
|
713
|
+
};
|
|
714
|
+
visit(value);
|
|
715
|
+
}
|
|
716
|
+
function forbiddenOperator(key) {
|
|
717
|
+
throw new Error(`MongoDB operator "${key}" is forbidden.`);
|
|
718
|
+
}
|
|
719
|
+
function numericOption(value, name) {
|
|
720
|
+
const number = typeof value === "number"
|
|
721
|
+
? value
|
|
722
|
+
: bsonTypeName(value) === "Int32" || bsonTypeName(value) === "Long" || bsonTypeName(value) === "Double"
|
|
723
|
+
? Number(value.valueOf())
|
|
724
|
+
: Number.NaN;
|
|
725
|
+
if (!Number.isSafeInteger(number) || number < 0 || number > 2_147_483_647) {
|
|
726
|
+
throw new Error(`MongoDB ${name} must be an integer from 0 through 2147483647.`);
|
|
727
|
+
}
|
|
728
|
+
return number;
|
|
729
|
+
}
|
|
730
|
+
function normalizeSort(value) {
|
|
731
|
+
if (!Array.isArray(value))
|
|
732
|
+
return value;
|
|
733
|
+
return value.map((item) => {
|
|
734
|
+
if (!Array.isArray(item))
|
|
735
|
+
return item;
|
|
736
|
+
return item.map((part, index) => index === 1 ? numericBsonValue(part) : part);
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
function numericBsonValue(value) {
|
|
740
|
+
const type = bsonTypeName(value);
|
|
741
|
+
return type === "Int32" || type === "Long" || type === "Double"
|
|
742
|
+
? Number(value.valueOf())
|
|
743
|
+
: value;
|
|
744
|
+
}
|
|
745
|
+
function isSort(value) {
|
|
746
|
+
return typeof value === "string" || Array.isArray(value) || isDocument(value);
|
|
747
|
+
}
|
|
748
|
+
function isDocument(value) {
|
|
749
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value) && !bsonTypeName(value);
|
|
750
|
+
}
|
|
751
|
+
function isRecord(value) {
|
|
752
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
753
|
+
}
|
|
754
|
+
function isMongoWriteOperation(value) {
|
|
755
|
+
return value === "insertOne" || value === "insertMany" ||
|
|
756
|
+
value === "updateOne" || value === "updateMany" ||
|
|
757
|
+
value === "replaceOne" || value === "deleteOne" || value === "deleteMany";
|
|
758
|
+
}
|
|
759
|
+
function bsonTypeName(value) {
|
|
760
|
+
if (!value || typeof value !== "object")
|
|
761
|
+
return undefined;
|
|
762
|
+
const type = value._bsontype;
|
|
763
|
+
return typeof type === "string" ? type : undefined;
|
|
764
|
+
}
|
|
765
|
+
function parseEjson(value) {
|
|
766
|
+
try {
|
|
767
|
+
return BSON.EJSON.parse(value, { relaxed: false });
|
|
768
|
+
}
|
|
769
|
+
catch {
|
|
770
|
+
throw new Error("Stored MongoDB command is not valid EJSON.");
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
function ejsonSafe(value) {
|
|
774
|
+
if (value === undefined)
|
|
775
|
+
return null;
|
|
776
|
+
return JSON.parse(BSON.EJSON.stringify(value, { relaxed: false }));
|
|
777
|
+
}
|
|
778
|
+
function remainingMilliseconds(context) {
|
|
779
|
+
return Math.max(1, Math.min(2_147_483_647, Math.ceil(context.deadline - Date.now())));
|
|
780
|
+
}
|
|
781
|
+
function operationSignal(context) {
|
|
782
|
+
const deadlineSignal = AbortSignal.timeout(remainingMilliseconds(context));
|
|
783
|
+
return context.signal ? AbortSignal.any([context.signal, deadlineSignal]) : deadlineSignal;
|
|
784
|
+
}
|
|
785
|
+
function throwIfStopped(context, outcomeUnknown) {
|
|
786
|
+
if (context.signal?.aborted || context.deadline <= Date.now()) {
|
|
787
|
+
throw stoppedError(context, outcomeUnknown);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
function stoppedError(context, outcomeUnknown) {
|
|
791
|
+
const aborted = context.signal?.aborted ?? false;
|
|
792
|
+
return new AdapterExecutionError(aborted ? "Database operation cancelled." : "Database operation timed out.", aborted ? "aborted" : "timeout", outcomeUnknown);
|
|
793
|
+
}
|
|
794
|
+
function withContext(promise, context, onStop, outcomeUnknown) {
|
|
795
|
+
try {
|
|
796
|
+
throwIfStopped(context, outcomeUnknown);
|
|
797
|
+
}
|
|
798
|
+
catch (error) {
|
|
799
|
+
return Promise.reject(error);
|
|
800
|
+
}
|
|
801
|
+
return new Promise((resolve, reject) => {
|
|
802
|
+
let settled = false;
|
|
803
|
+
const finish = (action) => {
|
|
804
|
+
if (settled)
|
|
805
|
+
return;
|
|
806
|
+
settled = true;
|
|
807
|
+
clearTimeout(timer);
|
|
808
|
+
context.signal?.removeEventListener("abort", stop);
|
|
809
|
+
action();
|
|
810
|
+
};
|
|
811
|
+
const stop = () => finish(() => {
|
|
812
|
+
try {
|
|
813
|
+
onStop();
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
// Preserve cancellation/deadline error.
|
|
817
|
+
}
|
|
818
|
+
reject(stoppedError(context, outcomeUnknown));
|
|
819
|
+
});
|
|
820
|
+
const timer = setTimeout(stop, remainingMilliseconds(context));
|
|
821
|
+
context.signal?.addEventListener("abort", stop, { once: true });
|
|
822
|
+
promise.then((result) => context.signal?.aborted || context.deadline <= Date.now()
|
|
823
|
+
? stop()
|
|
824
|
+
: finish(() => resolve(result)), (error) => context.signal?.aborted || context.deadline <= Date.now()
|
|
825
|
+
? stop()
|
|
826
|
+
: finish(() => reject(error)));
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
function readError(error, context) {
|
|
830
|
+
if (error instanceof AdapterExecutionError)
|
|
831
|
+
return error;
|
|
832
|
+
if (isTimeoutError(error) || context.signal?.aborted || context.deadline <= Date.now()) {
|
|
833
|
+
return stoppedError(context, false);
|
|
834
|
+
}
|
|
835
|
+
return error;
|
|
836
|
+
}
|
|
837
|
+
function writeStoppedError(error, context, outcomeUnknown) {
|
|
838
|
+
if (error instanceof AdapterExecutionError) {
|
|
839
|
+
return new AdapterExecutionError(error.message, error.reason, outcomeUnknown);
|
|
840
|
+
}
|
|
841
|
+
if (isTimeoutError(error) || context.signal?.aborted || context.deadline <= Date.now()) {
|
|
842
|
+
return stoppedError(context, outcomeUnknown);
|
|
843
|
+
}
|
|
844
|
+
return undefined;
|
|
845
|
+
}
|
|
846
|
+
function isTimeoutError(error) {
|
|
847
|
+
if (!error || typeof error !== "object")
|
|
848
|
+
return false;
|
|
849
|
+
const name = error.name;
|
|
850
|
+
const code = error.code;
|
|
851
|
+
return name === "MongoOperationTimeoutError" || name === "MongoNetworkTimeoutError" ||
|
|
852
|
+
code === 50 || code === "ETIMEDOUT";
|
|
853
|
+
}
|
|
854
|
+
function knownNoWrite(error) {
|
|
855
|
+
if (!error || typeof error !== "object")
|
|
856
|
+
return false;
|
|
857
|
+
const name = String(error.name ?? "");
|
|
858
|
+
const code = error.code;
|
|
859
|
+
if (["MongoInvalidArgumentError", "MongoParseError", "MongoCompatibilityError"].includes(name))
|
|
860
|
+
return true;
|
|
861
|
+
if ([2, 9, 14, 20, 59, 72, 303].includes(code))
|
|
862
|
+
return true;
|
|
863
|
+
const message = errorText(error);
|
|
864
|
+
return /transaction numbers are only allowed|does not support transactions|transactions are not supported/i.test(message);
|
|
865
|
+
}
|
|
866
|
+
function errorText(error) {
|
|
867
|
+
return error instanceof Error ? error.message : String(error);
|
|
868
|
+
}
|