@alexify/migronaut 2.1.0 → 2.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 (71) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/README.md +68 -10
  3. package/bullmq.d.ts +465 -7
  4. package/index.d.ts +1272 -18
  5. package/migronaut.schema.json +150 -2
  6. package/package.json +8 -2
  7. package/src/bullmq/background-processor.js +469 -0
  8. package/src/bullmq/index.js +12 -0
  9. package/src/bullmq/jobs.js +254 -7
  10. package/src/bullmq/processor.js +153 -15
  11. package/src/bullmq/producer.js +202 -27
  12. package/src/bullmq/service.js +480 -45
  13. package/src/cli/commands/background.js +500 -0
  14. package/src/cli/commands/converge.js +38 -10
  15. package/src/cli/commands/create.js +6 -0
  16. package/src/cli/exit-codes.js +6 -0
  17. package/src/cli/index.js +2 -0
  18. package/src/cli/table.js +68 -9
  19. package/src/core/audit.js +98 -3
  20. package/src/core/background-audit.js +139 -0
  21. package/src/core/background-drift.js +126 -0
  22. package/src/core/background-dry-run.js +366 -0
  23. package/src/core/background-engine.js +818 -0
  24. package/src/core/background-kit.js +425 -0
  25. package/src/core/background-partition.js +298 -0
  26. package/src/core/background-runner.js +305 -0
  27. package/src/core/background-sandbox.js +701 -0
  28. package/src/core/background-shard.js +542 -0
  29. package/src/core/background-spec.js +597 -0
  30. package/src/core/background-store.js +951 -0
  31. package/src/core/background-throttle.js +269 -0
  32. package/src/core/background-watch-plan.js +164 -0
  33. package/src/core/background-watch-store.js +78 -0
  34. package/src/core/background-watch.js +605 -0
  35. package/src/core/background.js +1121 -0
  36. package/src/core/bson-peer.js +23 -0
  37. package/src/core/changelog.js +32 -0
  38. package/src/core/collections.js +125 -31
  39. package/src/core/config.js +133 -13
  40. package/src/core/converge-plan.js +343 -61
  41. package/src/core/converge-search-run.js +440 -0
  42. package/src/core/converge-search.js +404 -0
  43. package/src/core/converge.js +428 -183
  44. package/src/core/index-spec.js +27 -16
  45. package/src/core/lock.js +97 -32
  46. package/src/core/migrator.js +951 -26
  47. package/src/core/options.js +32 -1
  48. package/src/core/run.js +26 -12
  49. package/src/core/runner.js +1 -1
  50. package/src/core/search-index-spec.js +758 -0
  51. package/src/core/server-info.js +70 -0
  52. package/src/core/shard-info.js +76 -0
  53. package/src/core/versioning-spec.js +181 -0
  54. package/src/errors/index.js +97 -5
  55. package/src/index.js +16 -0
  56. package/src/utils/canonical.js +34 -1
  57. package/src/utils/error.js +11 -2
  58. package/src/utils/loader.js +77 -9
  59. package/src/utils/migration-name.js +33 -1
  60. package/src/utils/telemetry.js +125 -1
  61. package/src/utils/template.js +69 -1
  62. package/src/versioning/config.js +155 -0
  63. package/src/versioning/document.js +326 -0
  64. package/src/versioning/index.js +50 -0
  65. package/src/versioning/internal.js +279 -0
  66. package/src/versioning/mongoose.js +151 -0
  67. package/src/versioning/occ.js +318 -0
  68. package/src/versioning/registry.js +187 -0
  69. package/src/versioning/upcaster.js +213 -0
  70. package/versioning.d.ts +666 -0
  71. package/versioning.js +1 -0
@@ -0,0 +1,366 @@
1
+ const { ConfigInvalidError } = require('../errors/index.js');
2
+ const { errorText } = require('../utils/error.js');
3
+ const { cloneDocument, stampedDiff } = require('../versioning/document.js');
4
+ const {
5
+ applyBatch,
6
+ buildStepContext,
7
+ idKey,
8
+ matchOf,
9
+ readStepResult,
10
+ transformAll,
11
+ transformContext,
12
+ } = require('./background-engine.js');
13
+ const { idRangePartitioner } = require('./background-partition.js');
14
+ const { runSandbox } = require('./background-sandbox.js');
15
+ const { toRelaxedEjson } = require('./bson-peer.js');
16
+ const { READ_OPTIONS } = require('./server-info.js');
17
+
18
+ /**
19
+ * Dry runs of a background migration — what it would do, on real documents,
20
+ * with nothing written. Works for a file that is not registered yet: the
21
+ * point is to look before `up`.
22
+ *
23
+ * - On a sample (`$sample`, or the `first` n by `_id`): each document before
24
+ * and after its transformation — the transformation alone.
25
+ * - With `validate`: the real write path (`applyBatch` — the guarded write,
26
+ * the diff, the side writes) inside the always-aborted sandbox, so what
27
+ * comes back is what the server would have stored, and what the validator
28
+ * or a unique index would have refused.
29
+ *
30
+ * Nothing here logs a document: they are the application's data.
31
+ */
32
+
33
+ const MAX_SAMPLE = 1000;
34
+
35
+ /** `value` as relaxed EJSON — what a person (or `--json`) can read */
36
+ const ejson = (value) => {
37
+ try {
38
+ return toRelaxedEjson(value);
39
+ } catch {
40
+ return { $unserializable: true };
41
+ }
42
+ };
43
+
44
+ function sampleSize({ sample, first }) {
45
+ const n = first ?? sample ?? 5;
46
+ if (!Number.isSafeInteger(n) || n < 1 || n > MAX_SAMPLE) {
47
+ throw new ConfigInvalidError(`The sample must be 1 to ${MAX_SAMPLE} documents`, { sample: n });
48
+ }
49
+ if (sample !== undefined && first !== undefined) {
50
+ throw new ConfigInvalidError('Take either a random sample or the first documents, not both');
51
+ }
52
+ return n;
53
+ }
54
+
55
+ /** The job a dry run works with — no partition, no lease */
56
+ function dryJob(name, loaded, direction, logger) {
57
+ return {
58
+ name,
59
+ spec: loaded.spec,
60
+ fns: loaded.fns,
61
+ direction,
62
+ partitioner: idRangePartitioner,
63
+ generation: 0,
64
+ partitionId: 'dry-run',
65
+ match: matchOf(loaded.spec, direction),
66
+ logger,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Whether a collection is sharded, for the sandbox — asked only behind a
72
+ * mongos; a key that cannot be read is left to the server to judge.
73
+ */
74
+ function shardedCheck(deps) {
75
+ return async (name) =>
76
+ (await deps.topology()) === 'sharded' && Boolean(await deps.shardKeyOf?.(name));
77
+ }
78
+
79
+ /**
80
+ * Preview a declarative background migration on a sample. `deps`:
81
+ * `{ db, client, topology, forbidden, logger, shardKeyOf? }`; `loaded`: `{ spec, fns }`.
82
+ */
83
+ async function previewSample(deps, name, loaded, options = {}) {
84
+ const { spec } = loaded;
85
+ if (spec.mode !== 'declarative') {
86
+ throw new ConfigInvalidError(
87
+ `${name} is a step background migration — dry-run it by steps, not on a sample`,
88
+ { migration: name },
89
+ );
90
+ }
91
+ const direction = options.direction === 'revert' ? 'revert' : 'forward';
92
+ if (direction === 'revert' && !spec.reversible) {
93
+ throw new ConfigInvalidError(`${name} declares no revert`, { migration: name });
94
+ }
95
+ const n = sampleSize(options);
96
+ const job = dryJob(name, loaded, direction, deps.logger);
97
+ const collection = deps.db.collection(spec.collection);
98
+ const docs =
99
+ options.first !== undefined
100
+ ? await collection.find(job.match, { sort: { _id: 1 }, limit: n, ...READ_OPTIONS }).toArray()
101
+ : await collection
102
+ .aggregate([{ $match: job.match }, { $sample: { size: n } }], READ_OPTIONS)
103
+ .toArray();
104
+ const base = {
105
+ mode: 'declarative',
106
+ migration: name,
107
+ direction,
108
+ method: options.first !== undefined ? 'first' : 'sample',
109
+ requested: n,
110
+ found: docs.length,
111
+ };
112
+ if (!options.validate) {
113
+ const rows = await transformRows(job, docs);
114
+ return { ...base, ...tally(rows), documents: rows };
115
+ }
116
+ return {
117
+ ...base,
118
+ ...(await validateRows(deps, job, docs, { deadlineMs: deadlineOf(options) })),
119
+ };
120
+ }
121
+
122
+ /** Each document before, and after its transformation as it would be written */
123
+ async function transformRows(job, docs) {
124
+ const ctx = transformContext(job, { dryRun: true });
125
+ const transformed = await transformAll(job, docs, ctx);
126
+ const target = job.direction === 'revert' ? job.spec.from : job.spec.to;
127
+ const rows = [];
128
+ for (const { doc, next, error } of transformed) {
129
+ const row = { _id: ejson(doc._id), before: ejson(doc) };
130
+ if (error !== undefined) {
131
+ row.error = errorText(error);
132
+ } else {
133
+ try {
134
+ const update = stampedDiff(doc, next, job.spec, { to: target });
135
+ row.change = ejson(update);
136
+ row.after = ejson(applyLocally(doc, update));
137
+ } catch (shapeError) {
138
+ row.error = errorText(shapeError);
139
+ }
140
+ }
141
+ rows.push(row);
142
+ }
143
+ return rows;
144
+ }
145
+
146
+ /** `doc` with a stamped operator update applied, top-level only — a preview, not the server */
147
+ function applyLocally(doc, update) {
148
+ const out = cloneDocument(doc);
149
+ for (const [key, value] of Object.entries(update.$set ?? {})) out[key] = value;
150
+ for (const key of Object.keys(update.$unset ?? {})) delete out[key];
151
+ for (const [key, value] of Object.entries(update.$inc ?? {})) out[key] = (out[key] ?? 0) + value;
152
+ return out;
153
+ }
154
+
155
+ function tally(rows) {
156
+ let migrated = 0;
157
+ let failed = 0;
158
+ for (const row of rows) {
159
+ if (row.error === undefined) migrated += 1;
160
+ else failed += 1;
161
+ }
162
+ return { migrated, failed };
163
+ }
164
+
165
+ /**
166
+ * Each document through the real write path in the sandbox, one at a time.
167
+ * A write error aborts the sandbox's transaction on the server — so the rest
168
+ * go on in a fresh sandbox, one transaction per failure, not per document.
169
+ */
170
+ /** The longest a sandbox runs — under the server's 60-second transaction limit */
171
+ const MAX_DEADLINE_MS = 50_000;
172
+
173
+ /** `deadlineMs`, checked: 1 to 50 000 (the default) */
174
+ function deadlineOf(options) {
175
+ const ms = options.deadlineMs ?? MAX_DEADLINE_MS;
176
+ if (!Number.isSafeInteger(ms) || ms < 1 || ms > MAX_DEADLINE_MS) {
177
+ throw new ConfigInvalidError(`deadlineMs must be an integer from 1 to ${MAX_DEADLINE_MS}`, {
178
+ deadlineMs: ms,
179
+ });
180
+ }
181
+ return ms;
182
+ }
183
+
184
+ async function validateRows(deps, job, docs, { deadlineMs } = {}) {
185
+ const rows = new Map();
186
+ const ops = [];
187
+ const refusals = [];
188
+ const sideEffects = [];
189
+ let remaining = docs;
190
+ let attempts = 0;
191
+ while (remaining.length > 0) {
192
+ let done = 0;
193
+ const report = await runSandbox(
194
+ {
195
+ client: deps.client,
196
+ db: deps.db,
197
+ forbidden: deps.forbidden,
198
+ topology: await deps.topology(),
199
+ isSharded: shardedCheck(deps),
200
+ ...(deadlineMs !== undefined ? { deadlineMs } : {}),
201
+ },
202
+ async (handles) => {
203
+ for (const doc of remaining) {
204
+ const result = await applyBatch(job, [doc], {
205
+ db: handles.db,
206
+ bare: true,
207
+ ctxExtra: {
208
+ db: handles.db,
209
+ client: handles.client,
210
+ session: handles.session,
211
+ dryRun: true,
212
+ },
213
+ });
214
+ const row = { _id: ejson(doc._id), before: ejson(doc) };
215
+ const [failure] = result.errors;
216
+ if (failure !== undefined) {
217
+ row.error = failure.error;
218
+ row.validation = 'failed';
219
+ } else if (result.migrated === 1) {
220
+ const stored = await handles.db
221
+ .collection(job.spec.collection)
222
+ .findOne({ _id: doc._id });
223
+ row.after = ejson(stored);
224
+ row.validation = 'ok';
225
+ } else {
226
+ row.validation = 'skipped';
227
+ }
228
+ rows.set(idKey(doc._id), row);
229
+ done += 1;
230
+ // A failed write ended the transaction: the rest go to a new sandbox.
231
+ if (failure?.reason === 'write') return;
232
+ }
233
+ },
234
+ );
235
+ attempts += report.attempts;
236
+ ops.push(...report.ops);
237
+ refusals.push(...report.refusals);
238
+ for (const document of report.documents) {
239
+ if (document.collection !== job.spec.collection) sideEffects.push(document);
240
+ }
241
+ const stopped =
242
+ report.error ??
243
+ (report.stoppedBy === 'deadline' ? 'the dry run reached its deadline' : undefined);
244
+ if (stopped !== undefined && done < remaining.length) {
245
+ // The sandbox stopped on something else (a refusal, the deadline): say so on the next row.
246
+ const doc = remaining[done];
247
+ rows.set(idKey(doc._id), {
248
+ _id: ejson(doc._id),
249
+ before: ejson(doc),
250
+ error: stopped,
251
+ validation: 'failed',
252
+ });
253
+ done += 1;
254
+ }
255
+ remaining = remaining.slice(Math.max(1, done));
256
+ }
257
+ const documents = docs.map((doc) => rows.get(idKey(doc._id)));
258
+ return {
259
+ validated: true,
260
+ aborted: true,
261
+ ...tally(documents),
262
+ documents,
263
+ ops,
264
+ refusals,
265
+ sideEffects,
266
+ attempts,
267
+ };
268
+ }
269
+
270
+ const MAX_STEPS = 50;
271
+
272
+ /**
273
+ * Dry-run a step migration: up to `steps` calls of `step` in one sandbox —
274
+ * one transaction, always aborted — each handed the checkpoint the one
275
+ * before returned, so a step sees what the earlier ones wrote. It starts
276
+ * from the checkpoint the background migration is at (`checkpoint`), or
277
+ * from none (`fromStart`, or not registered). No lock, no lease, no state.
278
+ */
279
+ async function previewSteps(deps, name, loaded, options = {}) {
280
+ const { spec, fns } = loaded;
281
+ const direction = options.direction === 'revert' ? 'revert' : 'forward';
282
+ const fn = direction === 'revert' ? fns.revertStep : fns.step;
283
+ if (typeof fn !== 'function') {
284
+ throw new ConfigInvalidError(`${name} declares no revertStep`, { migration: name });
285
+ }
286
+ if (options.validate || options.sample !== undefined || options.first !== undefined) {
287
+ throw new ConfigInvalidError(
288
+ `${name} is a step background migration — dry-run it by steps, not on a sample`,
289
+ { migration: name },
290
+ );
291
+ }
292
+ const steps = options.steps ?? 1;
293
+ if (!Number.isSafeInteger(steps) || steps < 1 || steps > MAX_STEPS) {
294
+ throw new ConfigInvalidError(`steps must be 1 to ${MAX_STEPS}`, { steps });
295
+ }
296
+ const job = {
297
+ name,
298
+ spec,
299
+ fns,
300
+ direction,
301
+ generation: 0,
302
+ partitionId: 'dry-run',
303
+ logger: deps.logger,
304
+ };
305
+ const log = [];
306
+ let stoppedBy = 'steps';
307
+ const deadlineMs = deadlineOf(options);
308
+ const report = await runSandbox(
309
+ {
310
+ client: deps.client,
311
+ db: deps.db,
312
+ forbidden: deps.forbidden,
313
+ topology: await deps.topology(),
314
+ isSharded: shardedCheck(deps),
315
+ deadlineMs,
316
+ ...(options.maxDocuments !== undefined ? { maxDocuments: options.maxDocuments } : {}),
317
+ },
318
+ async (handles) => {
319
+ let checkpoint = options.fromStart ? null : (options.checkpoint ?? null);
320
+ log.length = 0;
321
+ for (let step = 1; step <= steps; step++) {
322
+ handles.nextStep();
323
+ const entry = { step, checkpointIn: ejson(checkpoint) };
324
+ log.push(entry);
325
+ const ctx = {
326
+ ...buildStepContext(job, {
327
+ db: handles.db,
328
+ client: handles.client,
329
+ session: handles.session,
330
+ checkpoint,
331
+ deadline: Date.now() + deadlineMs,
332
+ dryRun: true,
333
+ }),
334
+ mongoose: handles.mongoose,
335
+ };
336
+ let result;
337
+ try {
338
+ result = readStepResult(await fn(ctx));
339
+ } catch (error) {
340
+ entry.error = errorText(error);
341
+ throw error;
342
+ }
343
+ entry.checkpointOut = ejson(result.checkpoint);
344
+ entry.done = result.done;
345
+ if (result.counters.processed !== undefined) entry.processed = result.counters.processed;
346
+ if (result.counters.migrated !== undefined) entry.migrated = result.counters.migrated;
347
+ if (result.done) {
348
+ stoppedBy = 'done';
349
+ return;
350
+ }
351
+ checkpoint = result.checkpoint;
352
+ }
353
+ },
354
+ );
355
+ const { value: _value, ...rest } = report;
356
+ return {
357
+ mode: 'step',
358
+ migration: name,
359
+ direction,
360
+ ...rest,
361
+ stoppedBy: report.stoppedBy ?? stoppedBy,
362
+ steps: log,
363
+ };
364
+ }
365
+
366
+ module.exports = { MAX_SAMPLE, MAX_STEPS, previewSample, previewSteps };