@alexify/migronaut 2.2.0 → 2.4.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/CHANGELOG.md +190 -0
- package/README.md +41 -3
- package/bullmq.d.ts +484 -8
- package/index.d.ts +1264 -9
- package/migronaut.schema.json +93 -1
- package/package.json +9 -2
- package/src/bullmq/background-processor.js +541 -0
- package/src/bullmq/index.js +12 -0
- package/src/bullmq/jobs.js +254 -7
- package/src/bullmq/processor.js +348 -21
- package/src/bullmq/producer.js +185 -13
- package/src/bullmq/service.js +484 -45
- package/src/cli/commands/background.js +500 -0
- package/src/cli/commands/create.js +6 -0
- package/src/cli/exit-codes.js +6 -0
- package/src/cli/index.js +2 -0
- package/src/core/audit.js +11 -1
- package/src/core/background-audit.js +139 -0
- package/src/core/background-drift.js +126 -0
- package/src/core/background-dry-run.js +375 -0
- package/src/core/background-engine.js +849 -0
- package/src/core/background-kit.js +432 -0
- package/src/core/background-partition.js +298 -0
- package/src/core/background-runner.js +305 -0
- package/src/core/background-sandbox.js +701 -0
- package/src/core/background-shard.js +542 -0
- package/src/core/background-spec.js +597 -0
- package/src/core/background-store.js +951 -0
- package/src/core/background-throttle.js +269 -0
- package/src/core/background-watch-plan.js +164 -0
- package/src/core/background-watch-store.js +78 -0
- package/src/core/background-watch.js +610 -0
- package/src/core/background.js +1127 -0
- package/src/core/bson-peer.js +23 -0
- package/src/core/changelog.js +32 -0
- package/src/core/collections.js +78 -8
- package/src/core/config.js +102 -12
- package/src/core/converge-plan.js +86 -7
- package/src/core/converge.js +88 -0
- package/src/core/lock.js +48 -21
- package/src/core/migration-logger.js +279 -0
- package/src/core/migrator.js +1027 -22
- package/src/core/options.js +36 -0
- package/src/core/run-recorder.js +6 -1
- package/src/core/run.js +26 -12
- package/src/core/runner.js +34 -8
- package/src/core/server-info.js +9 -2
- package/src/core/shard-info.js +76 -0
- package/src/core/versioning-spec.js +181 -0
- package/src/errors/index.js +88 -0
- package/src/index.js +16 -0
- package/src/utils/error.js +11 -2
- package/src/utils/job-ref.js +44 -0
- package/src/utils/loader.js +77 -9
- package/src/utils/migration-name.js +33 -1
- package/src/utils/redact.js +140 -3
- package/src/utils/telemetry.js +110 -0
- package/src/utils/template.js +62 -1
- package/src/versioning/config.js +155 -0
- package/src/versioning/document.js +326 -0
- package/src/versioning/index.js +50 -0
- package/src/versioning/internal.js +279 -0
- package/src/versioning/mongoose.js +151 -0
- package/src/versioning/occ.js +318 -0
- package/src/versioning/registry.js +187 -0
- package/src/versioning/upcaster.js +213 -0
- package/versioning.d.ts +666 -0
- package/versioning.js +1 -0
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
const {
|
|
2
|
+
BackgroundFailedError,
|
|
3
|
+
RunAbortedError,
|
|
4
|
+
SandboxRefusedError,
|
|
5
|
+
TransactionsUnsupportedError,
|
|
6
|
+
} = require('../errors/index.js');
|
|
7
|
+
const { isPlainObject } = require('../utils/canonical.js');
|
|
8
|
+
const { errorText } = require('../utils/error.js');
|
|
9
|
+
const { toRelaxedEjson } = require('./bson-peer.js');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A sandbox for dry runs: user code runs against the real database, inside
|
|
13
|
+
* a transaction that is **always** aborted — there is no path that commits —
|
|
14
|
+
* through proxies that let through only what can run in that transaction,
|
|
15
|
+
* record every operation, and keep before/after images of the documents it
|
|
16
|
+
* touched. What a `step` (or a declarative migration's own side writes)
|
|
17
|
+
* would do is shown on real data, and nothing of it stays.
|
|
18
|
+
*
|
|
19
|
+
* Fail closed: a method not on an allow-list is refused with
|
|
20
|
+
* `SandboxRefusedError` (DDL, admin commands, another session, `$out`,
|
|
21
|
+
* migronaut's own collections, …), and every refusal lands in the report
|
|
22
|
+
* even when the code catches the error.
|
|
23
|
+
*
|
|
24
|
+
* Not a security boundary: code that captured a client of its own, or
|
|
25
|
+
* requires the driver itself, can reach past the proxies. It is a guard
|
|
26
|
+
* against the obvious mistakes.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Collection methods that run inside the transaction: reads and writes */
|
|
30
|
+
const COLLECTION_READS = new Set(['find', 'findOne', 'aggregate', 'countDocuments', 'distinct']);
|
|
31
|
+
const COLLECTION_WRITES = new Set([
|
|
32
|
+
'insertOne',
|
|
33
|
+
'insertMany',
|
|
34
|
+
'updateOne',
|
|
35
|
+
'updateMany',
|
|
36
|
+
'replaceOne',
|
|
37
|
+
'deleteOne',
|
|
38
|
+
'deleteMany',
|
|
39
|
+
'findOneAndUpdate',
|
|
40
|
+
'findOneAndReplace',
|
|
41
|
+
'findOneAndDelete',
|
|
42
|
+
'bulkWrite',
|
|
43
|
+
]);
|
|
44
|
+
/** Plain properties of a collection, safe to read */
|
|
45
|
+
const COLLECTION_GETTERS = new Set(['collectionName', 'dbName', 'namespace']);
|
|
46
|
+
|
|
47
|
+
/** Cursor methods: builders return the cursor, terminals run it */
|
|
48
|
+
const CURSOR_BUILDERS = new Set([
|
|
49
|
+
'filter',
|
|
50
|
+
'sort',
|
|
51
|
+
'limit',
|
|
52
|
+
'skip',
|
|
53
|
+
'project',
|
|
54
|
+
'hint',
|
|
55
|
+
'batchSize',
|
|
56
|
+
'maxTimeMS',
|
|
57
|
+
'collation',
|
|
58
|
+
'comment',
|
|
59
|
+
'map',
|
|
60
|
+
'addStage',
|
|
61
|
+
'match',
|
|
62
|
+
'group',
|
|
63
|
+
'unwind',
|
|
64
|
+
]);
|
|
65
|
+
const CURSOR_TERMINALS = new Set(['toArray', 'next', 'hasNext', 'tryNext', 'close']);
|
|
66
|
+
|
|
67
|
+
/** Aggregation stages that cannot run in the sandbox */
|
|
68
|
+
const REFUSED_STAGES = new Set([
|
|
69
|
+
'$out',
|
|
70
|
+
'$merge',
|
|
71
|
+
'$changeStream',
|
|
72
|
+
'$changeStreamSplitLargeEvent',
|
|
73
|
+
'$currentOp',
|
|
74
|
+
'$collStats',
|
|
75
|
+
'$indexStats',
|
|
76
|
+
'$listSessions',
|
|
77
|
+
'$listLocalSessions',
|
|
78
|
+
'$planCacheStats',
|
|
79
|
+
'$listSearchIndexes',
|
|
80
|
+
'$querySettings',
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/** Options a sandboxed operation may not set — the sandbox owns them */
|
|
84
|
+
const REFUSED_OPTIONS = ['writeConcern', 'readConcern', 'bypassDocumentValidation'];
|
|
85
|
+
|
|
86
|
+
const DEFAULTS = Object.freeze({ deadlineMs: 50_000, maxDocuments: 20, maxOps: 1000 });
|
|
87
|
+
|
|
88
|
+
/** How much of a filter a recorded operation keeps */
|
|
89
|
+
const MAX_FILTER_BYTES = 2048;
|
|
90
|
+
|
|
91
|
+
/** How large one recorded document image may be */
|
|
92
|
+
const MAX_IMAGE_BYTES = 64 * 1024;
|
|
93
|
+
|
|
94
|
+
const ATTEMPTS = 3;
|
|
95
|
+
|
|
96
|
+
const isTransient = (error) =>
|
|
97
|
+
error?.hasErrorLabel?.('TransientTransactionError') === true || error?.code === 112;
|
|
98
|
+
|
|
99
|
+
/** A value as relaxed EJSON, cut to `bytes` of JSON */
|
|
100
|
+
function snapshot(value, bytes) {
|
|
101
|
+
let json;
|
|
102
|
+
try {
|
|
103
|
+
json = toRelaxedEjson(value);
|
|
104
|
+
} catch {
|
|
105
|
+
return { $unserializable: true };
|
|
106
|
+
}
|
|
107
|
+
const text = JSON.stringify(json);
|
|
108
|
+
return text.length > bytes ? { $truncated: true, size: text.length } : json;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Run `fn(handles)` in the sandbox. `options`: `{ client, db, dbName,
|
|
113
|
+
* forbidden: string[], topology?, deadlineMs, maxDocuments, maxOps }` —
|
|
114
|
+
* `forbidden` the collection names no operation may touch (migronaut's
|
|
115
|
+
* own). Resolves to `{ value, aborted: true, ok, attempts, stoppedBy?,
|
|
116
|
+
* ops, documents, refusals, leakedCursors, truncated, abortedBy?, error? }`.
|
|
117
|
+
*
|
|
118
|
+
* @throws {TransactionsUnsupportedError} on a standalone server
|
|
119
|
+
*/
|
|
120
|
+
async function runSandbox(options, fn) {
|
|
121
|
+
if (options.topology === 'standalone') {
|
|
122
|
+
throw new TransactionsUnsupportedError(
|
|
123
|
+
'A dry run executes in a transaction that is always aborted — it needs a replica set or ' +
|
|
124
|
+
'a mongos',
|
|
125
|
+
{ sandbox: true },
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
for (let attempt = 1; ; attempt++) {
|
|
129
|
+
const report = await runOnce(options, fn, attempt);
|
|
130
|
+
if (!report.retry || attempt >= ATTEMPTS) {
|
|
131
|
+
delete report.retry;
|
|
132
|
+
return report;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function runOnce(options, fn, attempt) {
|
|
138
|
+
const { client } = options;
|
|
139
|
+
const deadlineMs = options.deadlineMs ?? DEFAULTS.deadlineMs;
|
|
140
|
+
const maxDocuments = options.maxDocuments ?? DEFAULTS.maxDocuments;
|
|
141
|
+
const maxOps = options.maxOps ?? DEFAULTS.maxOps;
|
|
142
|
+
const session = client.startSession();
|
|
143
|
+
const report = {
|
|
144
|
+
aborted: true,
|
|
145
|
+
ok: true,
|
|
146
|
+
attempts: attempt,
|
|
147
|
+
ops: [],
|
|
148
|
+
documents: [],
|
|
149
|
+
refusals: [],
|
|
150
|
+
leakedCursors: 0,
|
|
151
|
+
truncated: false,
|
|
152
|
+
};
|
|
153
|
+
const state = {
|
|
154
|
+
options,
|
|
155
|
+
session,
|
|
156
|
+
report,
|
|
157
|
+
maxDocuments,
|
|
158
|
+
maxOps,
|
|
159
|
+
seq: 0,
|
|
160
|
+
step: 0,
|
|
161
|
+
queue: Promise.resolve(),
|
|
162
|
+
cursors: new Set(),
|
|
163
|
+
images: new Map(),
|
|
164
|
+
forbidden: new Set(options.forbidden ?? []),
|
|
165
|
+
deadline: Date.now() + deadlineMs,
|
|
166
|
+
failed: undefined,
|
|
167
|
+
};
|
|
168
|
+
let timer;
|
|
169
|
+
try {
|
|
170
|
+
session.startTransaction({ readConcern: { level: 'snapshot' } });
|
|
171
|
+
const handles = {
|
|
172
|
+
db: proxyDb(state, options.db),
|
|
173
|
+
client: proxyClient(state),
|
|
174
|
+
session: (state.sessionProxy = proxySession(state)),
|
|
175
|
+
mongoose: refusedMongoose(state),
|
|
176
|
+
/** For a caller that runs several steps: number the operations by step */
|
|
177
|
+
nextStep: () => {
|
|
178
|
+
state.step += 1;
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
const work = Promise.resolve().then(() => fn(handles));
|
|
182
|
+
const deadline = new Promise((resolve) => {
|
|
183
|
+
timer = setTimeout(() => resolve(DEADLINE), Math.max(0, state.deadline - Date.now()));
|
|
184
|
+
});
|
|
185
|
+
const value = await Promise.race([work, deadline]);
|
|
186
|
+
if (value === DEADLINE) {
|
|
187
|
+
report.stoppedBy = 'deadline';
|
|
188
|
+
work.catch(() => undefined);
|
|
189
|
+
} else {
|
|
190
|
+
report.value = value;
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (state.failed !== undefined) {
|
|
194
|
+
report.abortedBy = errorText(state.failed);
|
|
195
|
+
} else if (isTransient(error)) {
|
|
196
|
+
report.retry = true;
|
|
197
|
+
}
|
|
198
|
+
report.ok = false;
|
|
199
|
+
report.error = errorText(error);
|
|
200
|
+
} finally {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
// Any refusal makes the run not ok — even one the code swallowed.
|
|
203
|
+
if (report.refusals.length > 0) report.ok = false;
|
|
204
|
+
for (const cursor of state.cursors) {
|
|
205
|
+
report.leakedCursors += 1;
|
|
206
|
+
await cursor.close().catch(() => undefined);
|
|
207
|
+
}
|
|
208
|
+
await state.queue.catch(() => undefined);
|
|
209
|
+
await finishImages(state);
|
|
210
|
+
await session.abortTransaction().catch(() => undefined);
|
|
211
|
+
await session.endSession().catch(() => undefined);
|
|
212
|
+
}
|
|
213
|
+
return report;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const DEADLINE = Symbol('deadline');
|
|
217
|
+
|
|
218
|
+
/** Refuse `method`: recorded, then thrown */
|
|
219
|
+
function refuse(state, method, reason, collection) {
|
|
220
|
+
const entry = { method, reason, ...(collection !== undefined ? { collection } : {}) };
|
|
221
|
+
state.report.refusals.push(entry);
|
|
222
|
+
return new SandboxRefusedError(`The dry run refused ${method}: ${reason}`, entry);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Run `work` after every earlier operation — one at a time in one transaction */
|
|
226
|
+
function serialized(state, work) {
|
|
227
|
+
const run = state.queue.then(() => {
|
|
228
|
+
if (Date.now() > state.deadline) {
|
|
229
|
+
throw new RunAbortedError('the dry run reached its deadline', { reason: 'deadline' });
|
|
230
|
+
}
|
|
231
|
+
if (state.failed !== undefined) {
|
|
232
|
+
throw new BackgroundFailedError(`the transaction was aborted by ${errorText(state.failed)}`, {
|
|
233
|
+
reason: 'aborted',
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return work();
|
|
237
|
+
});
|
|
238
|
+
state.queue = run.catch(() => undefined);
|
|
239
|
+
return run;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The caller's options, checked and given the sandbox session */
|
|
243
|
+
function sandboxOptions(state, method, collection, options) {
|
|
244
|
+
if (options !== undefined && !isPlainObject(options)) return { session: state.session };
|
|
245
|
+
const out = { ...options };
|
|
246
|
+
if (
|
|
247
|
+
out.session !== undefined &&
|
|
248
|
+
out.session !== state.session &&
|
|
249
|
+
out.session !== state.sessionProxy
|
|
250
|
+
) {
|
|
251
|
+
throw refuse(state, method, 'it passes another session', collection);
|
|
252
|
+
}
|
|
253
|
+
for (const key of REFUSED_OPTIONS) {
|
|
254
|
+
if (out[key] !== undefined) throw refuse(state, method, `it sets ${key}`, collection);
|
|
255
|
+
}
|
|
256
|
+
const preference = out.readPreference;
|
|
257
|
+
const mode = typeof preference === 'string' ? preference : preference?.mode;
|
|
258
|
+
if (mode !== undefined && mode !== 'primary') {
|
|
259
|
+
throw refuse(state, method, 'it reads from a secondary', collection);
|
|
260
|
+
}
|
|
261
|
+
out.session = state.session;
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Refuse a pipeline that writes elsewhere, watches, or reaches a forbidden collection */
|
|
266
|
+
function checkPipeline(state, method, collection, pipeline) {
|
|
267
|
+
if (!Array.isArray(pipeline)) return;
|
|
268
|
+
for (const stage of pipeline) {
|
|
269
|
+
if (!isPlainObject(stage)) continue;
|
|
270
|
+
for (const [name, spec] of Object.entries(stage)) {
|
|
271
|
+
if (REFUSED_STAGES.has(name)) {
|
|
272
|
+
throw refuse(state, method, `it uses ${name}`, collection);
|
|
273
|
+
}
|
|
274
|
+
const target =
|
|
275
|
+
name === '$lookup' || name === '$graphLookup'
|
|
276
|
+
? spec?.from
|
|
277
|
+
: name === '$unionWith'
|
|
278
|
+
? (spec?.coll ?? spec)
|
|
279
|
+
: undefined;
|
|
280
|
+
if (typeof target === 'string' && isForbidden(state, target)) {
|
|
281
|
+
throw refuse(state, method, `it reads ${target}`, collection);
|
|
282
|
+
}
|
|
283
|
+
if (name === '$lookup' || name === '$unionWith') {
|
|
284
|
+
checkPipeline(state, method, collection, spec?.pipeline);
|
|
285
|
+
}
|
|
286
|
+
if (name === '$facet' && isPlainObject(spec)) {
|
|
287
|
+
for (const branch of Object.values(spec)) checkPipeline(state, method, collection, branch);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function isForbidden(state, name) {
|
|
294
|
+
return name.startsWith('system.') || state.forbidden.has(name);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Record an operation, while there is room */
|
|
298
|
+
function record(state, entry) {
|
|
299
|
+
if (state.report.ops.length >= state.maxOps) {
|
|
300
|
+
state.report.truncated = true;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
state.report.ops.push({ seq: ++state.seq, step: state.step, ...entry });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** What a result says, without its documents */
|
|
307
|
+
function summarize(result) {
|
|
308
|
+
if (result === null || result === undefined) return null;
|
|
309
|
+
if (Array.isArray(result)) return { count: result.length };
|
|
310
|
+
if (typeof result !== 'object') return result;
|
|
311
|
+
const summary = {};
|
|
312
|
+
for (const key of [
|
|
313
|
+
'acknowledged',
|
|
314
|
+
'insertedCount',
|
|
315
|
+
'matchedCount',
|
|
316
|
+
'modifiedCount',
|
|
317
|
+
'deletedCount',
|
|
318
|
+
'upsertedCount',
|
|
319
|
+
]) {
|
|
320
|
+
if (typeof result[key] === 'number' || typeof result[key] === 'boolean') {
|
|
321
|
+
summary[key] = result[key];
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (Object.keys(summary).length === 0 && '_id' in result) summary.document = true;
|
|
325
|
+
return summary;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ─── Document images ──────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
const imageKey = (collection, id) => `${collection}\u0000${JSON.stringify(snapshot(id, 1024))}`;
|
|
331
|
+
|
|
332
|
+
/** Before a write: read (in the transaction) what it may change, while there is room */
|
|
333
|
+
async function captureBefore(state, raw, method, args) {
|
|
334
|
+
if (state.images.size >= state.maxDocuments) {
|
|
335
|
+
state.report.truncated = state.report.truncated || COLLECTION_WRITES.has(method);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const filters = [];
|
|
339
|
+
if (method === 'bulkWrite' && Array.isArray(args[0])) {
|
|
340
|
+
for (const op of args[0]) {
|
|
341
|
+
const body =
|
|
342
|
+
op?.updateOne ?? op?.updateMany ?? op?.replaceOne ?? op?.deleteOne ?? op?.deleteMany;
|
|
343
|
+
if (body?.filter) {
|
|
344
|
+
filters.push({ filter: body.filter, many: Boolean(op.updateMany || op.deleteMany) });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
} else if (method !== 'insertOne' && method !== 'insertMany' && isPlainObject(args[0])) {
|
|
348
|
+
filters.push({ filter: args[0], many: method === 'updateMany' || method === 'deleteMany' });
|
|
349
|
+
}
|
|
350
|
+
for (const { filter, many } of filters) {
|
|
351
|
+
const left = state.maxDocuments - state.images.size;
|
|
352
|
+
if (left <= 0) {
|
|
353
|
+
state.report.truncated = true;
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const docs = await raw
|
|
357
|
+
.find(filter, { session: state.session, limit: many ? left : 1 })
|
|
358
|
+
.toArray()
|
|
359
|
+
.catch(() => []);
|
|
360
|
+
for (const doc of docs) {
|
|
361
|
+
const key = imageKey(raw.collectionName, doc._id);
|
|
362
|
+
if (!state.images.has(key)) {
|
|
363
|
+
state.images.set(key, { collection: raw.collectionName, id: doc._id, before: doc, raw });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** After a write: note the ids it created, so their after-images are read too */
|
|
370
|
+
function noteCreated(state, raw, method, result) {
|
|
371
|
+
const ids = [];
|
|
372
|
+
if (result?.insertedId !== undefined) ids.push(result.insertedId);
|
|
373
|
+
if (result?.insertedIds) ids.push(...Object.values(result.insertedIds));
|
|
374
|
+
if (result?.upsertedId !== undefined && result.upsertedId !== null) ids.push(result.upsertedId);
|
|
375
|
+
if (result?.upsertedIds) ids.push(...Object.values(result.upsertedIds));
|
|
376
|
+
if (method.startsWith('findOneAnd') && result?._id !== undefined) ids.push(result._id);
|
|
377
|
+
for (const id of ids) {
|
|
378
|
+
if (state.images.size >= state.maxDocuments) {
|
|
379
|
+
state.report.truncated = true;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const key = imageKey(raw.collectionName, id);
|
|
383
|
+
if (!state.images.has(key)) state.images.set(key, { collection: raw.collectionName, id, raw });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Read every after-image (still in the transaction) and keep the documents that changed */
|
|
388
|
+
async function finishImages(state) {
|
|
389
|
+
for (const image of state.images.values()) {
|
|
390
|
+
let after = null;
|
|
391
|
+
try {
|
|
392
|
+
after = await image.raw.findOne({ _id: image.id }, { session: state.session });
|
|
393
|
+
} catch {
|
|
394
|
+
// The transaction may be gone (a write error aborted it): no after-image.
|
|
395
|
+
after = undefined;
|
|
396
|
+
}
|
|
397
|
+
const before = image.before;
|
|
398
|
+
if (before !== undefined && after !== undefined && after !== null) {
|
|
399
|
+
if (
|
|
400
|
+
JSON.stringify(snapshot(before, Infinity)) === JSON.stringify(snapshot(after, Infinity))
|
|
401
|
+
) {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const op =
|
|
406
|
+
before === undefined
|
|
407
|
+
? 'insert'
|
|
408
|
+
: after === null
|
|
409
|
+
? 'delete'
|
|
410
|
+
: after === undefined
|
|
411
|
+
? 'unknown'
|
|
412
|
+
: 'update';
|
|
413
|
+
state.report.documents.push({
|
|
414
|
+
collection: image.collection,
|
|
415
|
+
_id: snapshot(image.id, MAX_FILTER_BYTES),
|
|
416
|
+
op,
|
|
417
|
+
...(before !== undefined ? { before: snapshot(before, MAX_IMAGE_BYTES) } : {}),
|
|
418
|
+
...(after ? { after: snapshot(after, MAX_IMAGE_BYTES) } : {}),
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ─── Proxies ──────────────────────────────────────────────────────────────────
|
|
424
|
+
|
|
425
|
+
/** Properties every proxy answers without refusing — `then` especially (never thenable) */
|
|
426
|
+
const INERT = new Set(['then', 'constructor', 'toJSON', 'inspect']);
|
|
427
|
+
|
|
428
|
+
function proxyCollection(state, raw) {
|
|
429
|
+
const name = raw.collectionName;
|
|
430
|
+
return new Proxy(Object.create(null), {
|
|
431
|
+
get(_target, property) {
|
|
432
|
+
if (typeof property === 'symbol' || INERT.has(property)) return undefined;
|
|
433
|
+
if (COLLECTION_GETTERS.has(property)) return raw[property];
|
|
434
|
+
if (property === 'find' || property === 'aggregate') {
|
|
435
|
+
return (...args) => readOp(state, raw, property, args);
|
|
436
|
+
}
|
|
437
|
+
// A promise-returning method refuses with a rejected promise, as the driver fails.
|
|
438
|
+
// `distinct` asks first whether the collection is sharded — where the
|
|
439
|
+
// server refuses it inside a transaction (263); a clearer refusal here.
|
|
440
|
+
if (property === 'distinct') {
|
|
441
|
+
return (...args) =>
|
|
442
|
+
settle(async () => {
|
|
443
|
+
if (await state.options.isSharded?.(name)) {
|
|
444
|
+
throw refuse(
|
|
445
|
+
state,
|
|
446
|
+
property,
|
|
447
|
+
'distinct cannot run in a transaction on a sharded collection',
|
|
448
|
+
name,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
return readOp(state, raw, property, args);
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
if (COLLECTION_READS.has(property)) {
|
|
455
|
+
return (...args) => settle(() => readOp(state, raw, property, args));
|
|
456
|
+
}
|
|
457
|
+
if (COLLECTION_WRITES.has(property)) {
|
|
458
|
+
return (...args) => settle(() => writeOp(state, raw, property, args));
|
|
459
|
+
}
|
|
460
|
+
return () => {
|
|
461
|
+
throw refuse(state, `collection.${property}`, 'not available in a dry run', name);
|
|
462
|
+
};
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** `fn()` as a promise — a synchronous throw becomes a rejection */
|
|
468
|
+
function settle(fn) {
|
|
469
|
+
try {
|
|
470
|
+
return Promise.resolve(fn());
|
|
471
|
+
} catch (error) {
|
|
472
|
+
return Promise.reject(error);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function readOp(state, raw, method, args) {
|
|
477
|
+
const name = raw.collectionName;
|
|
478
|
+
if (method === 'aggregate') checkPipeline(state, method, name, args[0]);
|
|
479
|
+
if (method === 'find' || method === 'aggregate') {
|
|
480
|
+
const optionsIndex = 1;
|
|
481
|
+
const options = sandboxOptions(state, method, name, args[optionsIndex]);
|
|
482
|
+
const cursor = raw[method](args[0] ?? (method === 'find' ? {} : []), options);
|
|
483
|
+
record(state, {
|
|
484
|
+
collection: name,
|
|
485
|
+
method,
|
|
486
|
+
filter: snapshot(args[0] ?? null, MAX_FILTER_BYTES),
|
|
487
|
+
result: 'cursor',
|
|
488
|
+
});
|
|
489
|
+
return proxyCursor(state, cursor);
|
|
490
|
+
}
|
|
491
|
+
const optionsIndex = method === 'distinct' ? 2 : 1;
|
|
492
|
+
const call = [...args];
|
|
493
|
+
call[optionsIndex] = sandboxOptions(state, method, name, args[optionsIndex]);
|
|
494
|
+
if (method === 'distinct' && call[1] === undefined) call[1] = {};
|
|
495
|
+
return serialized(state, async () => {
|
|
496
|
+
const started = Date.now();
|
|
497
|
+
try {
|
|
498
|
+
const result = await raw[method](...call);
|
|
499
|
+
record(state, {
|
|
500
|
+
collection: name,
|
|
501
|
+
method,
|
|
502
|
+
filter: snapshot(args[method === 'distinct' ? 1 : 0] ?? null, MAX_FILTER_BYTES),
|
|
503
|
+
result: summarize(result),
|
|
504
|
+
durationMs: Date.now() - started,
|
|
505
|
+
});
|
|
506
|
+
return result;
|
|
507
|
+
} catch (error) {
|
|
508
|
+
record(state, { collection: name, method, error: errorText(error) });
|
|
509
|
+
throw error;
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Where a write method takes its options */
|
|
515
|
+
const OPTIONS_AT = {
|
|
516
|
+
insertOne: 1,
|
|
517
|
+
insertMany: 1,
|
|
518
|
+
updateOne: 2,
|
|
519
|
+
updateMany: 2,
|
|
520
|
+
replaceOne: 2,
|
|
521
|
+
deleteOne: 1,
|
|
522
|
+
deleteMany: 1,
|
|
523
|
+
findOneAndUpdate: 2,
|
|
524
|
+
findOneAndReplace: 2,
|
|
525
|
+
findOneAndDelete: 1,
|
|
526
|
+
bulkWrite: 1,
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
function writeOp(state, raw, method, args) {
|
|
530
|
+
const name = raw.collectionName;
|
|
531
|
+
if (isForbidden(state, name)) throw refuse(state, method, `${name} is migronaut's own`, name);
|
|
532
|
+
const call = [...args];
|
|
533
|
+
const at = OPTIONS_AT[method];
|
|
534
|
+
call[at] = sandboxOptions(state, method, name, args[at]);
|
|
535
|
+
if (method === 'updateOne' || method === 'updateMany' || method === 'findOneAndUpdate') {
|
|
536
|
+
if (Array.isArray(args[1])) checkPipeline(state, method, name, args[1]);
|
|
537
|
+
}
|
|
538
|
+
return serialized(state, async () => {
|
|
539
|
+
await captureBefore(state, raw, method, args);
|
|
540
|
+
const started = Date.now();
|
|
541
|
+
try {
|
|
542
|
+
const result = await raw[method](...call);
|
|
543
|
+
noteCreated(state, raw, method, result);
|
|
544
|
+
record(state, {
|
|
545
|
+
collection: name,
|
|
546
|
+
method,
|
|
547
|
+
filter: snapshot(method.startsWith('insert') ? null : (args[0] ?? null), MAX_FILTER_BYTES),
|
|
548
|
+
result: summarize(result),
|
|
549
|
+
durationMs: Date.now() - started,
|
|
550
|
+
});
|
|
551
|
+
return result;
|
|
552
|
+
} catch (error) {
|
|
553
|
+
// A write error aborts the transaction on the server: nothing after it
|
|
554
|
+
// can run. A transient one (a write conflict) runs the whole sandbox again.
|
|
555
|
+
if (!isTransient(error)) state.failed = error;
|
|
556
|
+
record(state, { collection: name, method, error: errorText(error) });
|
|
557
|
+
throw error;
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function proxyCursor(state, cursor) {
|
|
563
|
+
state.cursors.add(cursor);
|
|
564
|
+
let closed = false;
|
|
565
|
+
const finish = () => {
|
|
566
|
+
closed = true;
|
|
567
|
+
state.cursors.delete(cursor);
|
|
568
|
+
};
|
|
569
|
+
const terminal =
|
|
570
|
+
(method) =>
|
|
571
|
+
(...args) => {
|
|
572
|
+
if (closed) throw refuse(state, `cursor.${method}`, 'the cursor is closed');
|
|
573
|
+
return serialized(state, async () => {
|
|
574
|
+
const result = await cursor[method](...args);
|
|
575
|
+
if (method === 'toArray' || method === 'close') finish();
|
|
576
|
+
if ((method === 'next' || method === 'tryNext') && result === null) finish();
|
|
577
|
+
return result;
|
|
578
|
+
});
|
|
579
|
+
};
|
|
580
|
+
const proxy = new Proxy(Object.create(null), {
|
|
581
|
+
get(_target, property) {
|
|
582
|
+
if (property === Symbol.asyncIterator) {
|
|
583
|
+
return async function* iterate() {
|
|
584
|
+
for (;;) {
|
|
585
|
+
const doc = await terminal('next')();
|
|
586
|
+
if (doc === null) return;
|
|
587
|
+
yield doc;
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
if (typeof property === 'symbol' || INERT.has(property)) return undefined;
|
|
592
|
+
if (CURSOR_BUILDERS.has(property)) {
|
|
593
|
+
return (...args) => {
|
|
594
|
+
// A stage added to an aggregation cursor is held to the pipeline's rules.
|
|
595
|
+
if (property === 'addStage') checkPipeline(state, 'cursor.addStage', undefined, args);
|
|
596
|
+
cursor[property](...args);
|
|
597
|
+
return proxy;
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
if (CURSOR_TERMINALS.has(property)) return terminal(property);
|
|
601
|
+
if (property === 'forEach') {
|
|
602
|
+
// Each document is read on its own turn; the callback runs outside the
|
|
603
|
+
// queue, so it may run operations of its own.
|
|
604
|
+
return async (callback) => {
|
|
605
|
+
for (;;) {
|
|
606
|
+
const doc = await terminal('next')();
|
|
607
|
+
if (doc === null) return;
|
|
608
|
+
if ((await callback(doc)) === false) {
|
|
609
|
+
await terminal('close')();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
return () => {
|
|
616
|
+
throw refuse(state, `cursor.${property}`, 'not available in a dry run');
|
|
617
|
+
};
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
return proxy;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function proxyDb(state, db) {
|
|
624
|
+
return new Proxy(Object.create(null), {
|
|
625
|
+
get(_target, property) {
|
|
626
|
+
if (typeof property === 'symbol' || INERT.has(property)) return undefined;
|
|
627
|
+
if (property === 'databaseName') return db.databaseName;
|
|
628
|
+
if (property === 'collection') {
|
|
629
|
+
return (name) => {
|
|
630
|
+
if (typeof name !== 'string' || isForbidden(state, name)) {
|
|
631
|
+
throw refuse(state, 'db.collection', `${String(name)} cannot be reached`, name);
|
|
632
|
+
}
|
|
633
|
+
return proxyCollection(state, db.collection(name));
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
if (property === 'aggregate') {
|
|
637
|
+
return (pipeline, options) => {
|
|
638
|
+
checkPipeline(state, 'db.aggregate', undefined, pipeline);
|
|
639
|
+
const cursor = db.aggregate(
|
|
640
|
+
pipeline,
|
|
641
|
+
sandboxOptions(state, 'db.aggregate', undefined, options),
|
|
642
|
+
);
|
|
643
|
+
record(state, { method: 'db.aggregate', filter: snapshot(pipeline, MAX_FILTER_BYTES) });
|
|
644
|
+
return proxyCursor(state, cursor);
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
return () => {
|
|
648
|
+
throw refuse(state, `db.${property}`, 'not available in a dry run');
|
|
649
|
+
};
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function proxyClient(state) {
|
|
655
|
+
const { db } = state.options;
|
|
656
|
+
return new Proxy(Object.create(null), {
|
|
657
|
+
get(_target, property) {
|
|
658
|
+
if (typeof property === 'symbol' || INERT.has(property)) return undefined;
|
|
659
|
+
if (property === 'db') {
|
|
660
|
+
return (name) => {
|
|
661
|
+
if (name !== undefined && name !== db.databaseName) {
|
|
662
|
+
throw refuse(state, 'client.db', `only ${db.databaseName} can be reached`);
|
|
663
|
+
}
|
|
664
|
+
return proxyDb(state, db);
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
return () => {
|
|
668
|
+
throw refuse(state, `client.${property}`, 'not available in a dry run');
|
|
669
|
+
};
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function proxySession(state) {
|
|
675
|
+
return new Proxy(Object.create(null), {
|
|
676
|
+
get(_target, property) {
|
|
677
|
+
if (typeof property === 'symbol' || INERT.has(property)) return undefined;
|
|
678
|
+
if (property === 'inTransaction') return () => true;
|
|
679
|
+
if (property === 'id') return state.session.id;
|
|
680
|
+
return () => {
|
|
681
|
+
throw refuse(state, `session.${property}`, 'the dry run owns the transaction');
|
|
682
|
+
};
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** What a dry run's `ctx.mongoose` is: nothing usable — models bypass the session */
|
|
688
|
+
function refusedMongoose(state) {
|
|
689
|
+
return new Proxy(Object.create(null), {
|
|
690
|
+
get(_target, property) {
|
|
691
|
+
if (typeof property === 'symbol' || INERT.has(property)) return undefined;
|
|
692
|
+
throw refuse(state, `mongoose.${property}`, 'models do not join the dry-run transaction');
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
module.exports = {
|
|
698
|
+
REFUSED_STAGES,
|
|
699
|
+
SANDBOX_DEFAULTS: DEFAULTS,
|
|
700
|
+
runSandbox,
|
|
701
|
+
};
|