@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.
Files changed (68) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +41 -3
  3. package/bullmq.d.ts +484 -8
  4. package/index.d.ts +1264 -9
  5. package/migronaut.schema.json +93 -1
  6. package/package.json +9 -2
  7. package/src/bullmq/background-processor.js +541 -0
  8. package/src/bullmq/index.js +12 -0
  9. package/src/bullmq/jobs.js +254 -7
  10. package/src/bullmq/processor.js +348 -21
  11. package/src/bullmq/producer.js +185 -13
  12. package/src/bullmq/service.js +484 -45
  13. package/src/cli/commands/background.js +500 -0
  14. package/src/cli/commands/create.js +6 -0
  15. package/src/cli/exit-codes.js +6 -0
  16. package/src/cli/index.js +2 -0
  17. package/src/core/audit.js +11 -1
  18. package/src/core/background-audit.js +139 -0
  19. package/src/core/background-drift.js +126 -0
  20. package/src/core/background-dry-run.js +375 -0
  21. package/src/core/background-engine.js +849 -0
  22. package/src/core/background-kit.js +432 -0
  23. package/src/core/background-partition.js +298 -0
  24. package/src/core/background-runner.js +305 -0
  25. package/src/core/background-sandbox.js +701 -0
  26. package/src/core/background-shard.js +542 -0
  27. package/src/core/background-spec.js +597 -0
  28. package/src/core/background-store.js +951 -0
  29. package/src/core/background-throttle.js +269 -0
  30. package/src/core/background-watch-plan.js +164 -0
  31. package/src/core/background-watch-store.js +78 -0
  32. package/src/core/background-watch.js +610 -0
  33. package/src/core/background.js +1127 -0
  34. package/src/core/bson-peer.js +23 -0
  35. package/src/core/changelog.js +32 -0
  36. package/src/core/collections.js +78 -8
  37. package/src/core/config.js +102 -12
  38. package/src/core/converge-plan.js +86 -7
  39. package/src/core/converge.js +88 -0
  40. package/src/core/lock.js +48 -21
  41. package/src/core/migration-logger.js +279 -0
  42. package/src/core/migrator.js +1027 -22
  43. package/src/core/options.js +36 -0
  44. package/src/core/run-recorder.js +6 -1
  45. package/src/core/run.js +26 -12
  46. package/src/core/runner.js +34 -8
  47. package/src/core/server-info.js +9 -2
  48. package/src/core/shard-info.js +76 -0
  49. package/src/core/versioning-spec.js +181 -0
  50. package/src/errors/index.js +88 -0
  51. package/src/index.js +16 -0
  52. package/src/utils/error.js +11 -2
  53. package/src/utils/job-ref.js +44 -0
  54. package/src/utils/loader.js +77 -9
  55. package/src/utils/migration-name.js +33 -1
  56. package/src/utils/redact.js +140 -3
  57. package/src/utils/telemetry.js +110 -0
  58. package/src/utils/template.js +62 -1
  59. package/src/versioning/config.js +155 -0
  60. package/src/versioning/document.js +326 -0
  61. package/src/versioning/index.js +50 -0
  62. package/src/versioning/internal.js +279 -0
  63. package/src/versioning/mongoose.js +151 -0
  64. package/src/versioning/occ.js +318 -0
  65. package/src/versioning/registry.js +187 -0
  66. package/src/versioning/upcaster.js +213 -0
  67. package/versioning.d.ts +666 -0
  68. package/versioning.js +1 -0
@@ -0,0 +1,139 @@
1
+ const { STATE_SUMMARY, probeHint } = require('./background.js');
2
+ const { probeOldShape } = require('./background-drift.js');
3
+
4
+ /**
5
+ * What `audit` says about background migrations — read-only, from what the
6
+ * kit injects (`deps`): the states, their partitions and leases, the files
7
+ * on disk, the changelog's background records and the live watchers.
8
+ */
9
+
10
+ /** How long a background migration may sit in `pending`, or `running` without progress, before audit warns */
11
+ const STALL_MS = 15 * 60_000;
12
+
13
+ /**
14
+ * What `audit` says about background migrations: `{ status, detail }` with
15
+ * the worst finding — or `null` when none is registered. `deps.checksumOf`
16
+ * reads the file on disk; `deps.backgroundRecords` the changelog's
17
+ * background records.
18
+ */
19
+ async function auditFindings(deps, { now = Date.now() } = {}) {
20
+ const states = await deps.store.list({}, { projection: STATE_SUMMARY });
21
+ const records = await deps.backgroundRecords();
22
+ if (states.length === 0 && records.length === 0) return null;
23
+ const failures = [];
24
+ const warnings = [];
25
+ const byName = new Map();
26
+ const running = [];
27
+ for (const state of states) {
28
+ byName.set(state._id, state);
29
+ if (state.status === 'running') running.push(state._id);
30
+ }
31
+ // Read once for all of them, not once per state.
32
+ const live = await deps.store.liveLeasesOf(running);
33
+ const hints = new Map();
34
+ const hintOf = async (spec) => {
35
+ const key = `${spec.collection}\u0000${spec.field}`;
36
+ if (!hints.has(key)) hints.set(key, await probeHint(deps, spec));
37
+ return hints.get(key);
38
+ };
39
+ const counts = {};
40
+ for (const state of states) {
41
+ const name = state._id;
42
+ counts[state.status] = (counts[state.status] ?? 0) + 1;
43
+ if (state.status === 'failed') {
44
+ failures.push(`${name} failed${state.lastError ? ` (${state.lastError})` : ''}`);
45
+ continue;
46
+ }
47
+ const registeredAt = new Date(state.registeredAt).getTime();
48
+ const progressAt = new Date(
49
+ state.lastProgressAt ?? state.startedAt ?? state.registeredAt,
50
+ ).getTime();
51
+ if (state.status === 'pending' && now - registeredAt > STALL_MS) {
52
+ warnings.push(
53
+ `${name} has been pending since ${new Date(registeredAt).toISOString()} — is a runner up?`,
54
+ );
55
+ }
56
+ if (state.status === 'paused') warnings.push(`${name} is paused`);
57
+ if (state.status === 'running') {
58
+ if ((live.get(name) ?? 0) === 0 && now - progressAt > STALL_MS) {
59
+ warnings.push(
60
+ `${name} is running but stalled — no lane for ${Math.round((now - progressAt) / 60_000)} min`,
61
+ );
62
+ }
63
+ }
64
+ if (state.status === 'blocked') {
65
+ for (const required of state.waitsFor ?? []) {
66
+ if (byName.get(required)?.status === 'failed') {
67
+ warnings.push(`${name} is blocked by ${required}, which failed`);
68
+ }
69
+ }
70
+ }
71
+ if (state.plan !== undefined) {
72
+ const current = await deps.store.partitionCounts(name, {
73
+ generation: state.generation,
74
+ plan: state.plan.token,
75
+ });
76
+ if (current.failed > 0 && state.status !== 'failed') {
77
+ warnings.push(`${name} has ${current.failed} failed partition(s)`);
78
+ }
79
+ // Of the current generation only: the done partitions of the pass before
80
+ // are kept on purpose (finalize drops the generation before last).
81
+ const orphaned = await deps.store.countForeignPlans(name, {
82
+ generation: state.generation,
83
+ plan: state.plan.token,
84
+ });
85
+ if (orphaned > 0) warnings.push(`${name} keeps ${orphaned} partition(s) of an old plan`);
86
+ }
87
+ if (state.status === 'completed') {
88
+ if ((state.badIds ?? []).length > 0) {
89
+ warnings.push(
90
+ `${name} completed with ${state.badIds.length} document(s) it could not migrate`,
91
+ );
92
+ }
93
+ if (state.spec?.mode === 'declarative' && state.direction !== 'revert') {
94
+ const hint = await hintOf(state.spec);
95
+ if (
96
+ hint !== undefined &&
97
+ (await probeOldShape(deps, state, hint).catch(() => null)) !== null
98
+ ) {
99
+ warnings.push(
100
+ `${state.spec.collection} holds old-shape documents again (${name} completed)`,
101
+ );
102
+ }
103
+ }
104
+ }
105
+ try {
106
+ const checksum = await deps.checksumOf(name);
107
+ if (state.checksum !== undefined && checksum !== state.checksum) {
108
+ warnings.push(`${name} changed on disk since it was registered (repin it)`);
109
+ }
110
+ } catch {
111
+ warnings.push(`${name} is registered but its file is missing`);
112
+ }
113
+ }
114
+ for (const record of records) {
115
+ if (!byName.has(record.name)) {
116
+ warnings.push(`${record.name} is applied but its background migration is not registered`);
117
+ }
118
+ }
119
+ // Live drift watchers, when drift is streamed: one left to the poll for
120
+ // long, or one nobody has led for long, is worth a look.
121
+ for (const row of (await deps.watchRows?.()) ?? []) {
122
+ const quietMs = now - new Date(row.updatedAt).getTime();
123
+ if (!(quietMs > STALL_MS)) continue;
124
+ const minutes = Math.round(quietMs / 60_000);
125
+ warnings.push(
126
+ row.state === 'fallback'
127
+ ? `the drift watcher of ${row._id} has fallen back to polling for ${minutes} min`
128
+ : `the drift watcher of ${row._id} has had no live leader for ${minutes} min`,
129
+ );
130
+ }
131
+ const summary = Object.entries(counts)
132
+ .map(([status, n]) => `${n} ${status}`)
133
+ .join(', ');
134
+ if (failures.length > 0) return { status: 'fail', detail: [...failures, ...warnings].join('; ') };
135
+ if (warnings.length > 0) return { status: 'warn', detail: warnings.join('; ') };
136
+ return { status: 'pass', detail: `${states.length} background migration(s): ${summary}` };
137
+ }
138
+
139
+ module.exports = { auditFindings };
@@ -0,0 +1,126 @@
1
+ const { errorText } = require('../utils/error.js');
2
+ const { STATE_SUMMARY, control, probeHint } = require('./background.js');
3
+ const { excludeBadIds, matchOf } = require('./background-engine.js');
4
+ const { READ_OPTIONS } = require('./server-info.js');
5
+
6
+ /**
7
+ * Drift: old-shape documents that appear after a background migration
8
+ * completed — an old pod, a forgotten worker, another service. The probes
9
+ * the requires guard, the poll (`verifyBackground`) and audit share: one
10
+ * hinted, time-boxed look per completed forward migration. Orchestration
11
+ * over what the kit injects (`deps`), as in background.js.
12
+ */
13
+
14
+ /** How long one drift probe may run */
15
+ const DRIFT_PROBE_MS = 5_000;
16
+
17
+ /** Statuses still at work on a collection — its drift is not drift yet */
18
+ const ACTIVE = new Set(['blocked', 'pending', 'running', 'paused']);
19
+
20
+ /** One indexed look for a document of a completed forward migration's old shape */
21
+ async function probeOldShape(deps, state, hint) {
22
+ const spec = state.spec;
23
+ return deps.db
24
+ .collection(spec.collection)
25
+ .findOne(excludeBadIds(matchOf(spec, 'forward'), state.badIds), {
26
+ projection: { _id: 1 },
27
+ hint,
28
+ maxTimeMS: DRIFT_PROBE_MS,
29
+ ...READ_OPTIONS,
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Whether a completed forward migration's collection holds an old-shape
35
+ * document again — for the `requires` guard, which runs under the migration
36
+ * lock: one hinted probe, time-boxed. Where that is not possible (a step
37
+ * migration, no version index, a probe that ran out of time) the status is
38
+ * trusted — a scan of the whole collection on every `up` is not an option.
39
+ */
40
+ async function stillDirty(deps, state) {
41
+ const spec = state.spec;
42
+ if (!spec || spec.mode !== 'declarative' || state.direction === 'revert') return false;
43
+ const hint = await probeHint(deps, spec);
44
+ if (hint === undefined) return false;
45
+ try {
46
+ return (await probeOldShape(deps, state, hint)) !== null;
47
+ } catch (error) {
48
+ deps.logger.warn(
49
+ `⚠ Could not check ${state._id} for old-shape documents: ${errorText(error)} — trusting its status`,
50
+ deps.fields({ background: state._id }),
51
+ );
52
+ return false;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * The drift watch: old-shape documents that appeared after a background
58
+ * migration completed — an old pod, a forgotten worker, another service.
59
+ * One indexed probe per completed forward (declarative) migration; skipped
60
+ * where another one is still at work on the collection, where the validator
61
+ * already refuses the old shape (`to ≤ versioning.min`), and where the
62
+ * version index is missing. With `onDrift: 'reopen'` a finding reopens it —
63
+ * a new pass over what is left, not a reset — and with `'report'` it is only
64
+ * said. Chains (v1→v2→v3) converge on their own. No document id is reported.
65
+ * `streaming`: collections a live watcher leads right now — skipped too.
66
+ */
67
+ async function verify(deps, { onDrift = 'reopen', collections, streaming } = {}) {
68
+ const states = await deps.store.list({}, { projection: STATE_SUMMARY });
69
+ const active = new Set();
70
+ for (const state of states) {
71
+ if (ACTIVE.has(state.status) && state.spec?.collection) active.add(state.spec.collection);
72
+ }
73
+ // In `backgroundDrift: 'stream'` mode a live watcher's collection is its, not the poll's.
74
+ for (const collection of streaming ?? []) active.add(collection);
75
+ const wanted = collections === undefined ? undefined : new Set(collections);
76
+ const result = { checked: 0, skipped: 0, drift: [] };
77
+ for (const state of states) {
78
+ const spec = state.spec;
79
+ if (state.status !== 'completed' || state.direction === 'revert') continue;
80
+ if (spec?.mode !== 'declarative') continue;
81
+ if (wanted !== undefined && !wanted.has(spec.collection)) continue;
82
+ const versioning = await deps.versioningOf?.(spec.collection);
83
+ if (active.has(spec.collection) || (versioning && spec.to <= versioning.min)) {
84
+ result.skipped += 1;
85
+ continue;
86
+ }
87
+ const hint = await probeHint(deps, spec);
88
+ if (hint === undefined) {
89
+ result.skipped += 1;
90
+ continue;
91
+ }
92
+ let found;
93
+ try {
94
+ found = await probeOldShape(deps, state, hint);
95
+ } catch (error) {
96
+ deps.logger.warn(
97
+ `⚠ Drift check of ${state._id} failed: ${errorText(error)}`,
98
+ deps.fields({ background: state._id }),
99
+ );
100
+ result.skipped += 1;
101
+ continue;
102
+ }
103
+ result.checked += 1;
104
+ if (found === null) continue;
105
+ const action = onDrift === 'reopen' ? 'reopened' : 'reported';
106
+ if (action === 'reopened') {
107
+ await control(deps, state._id, 'retry', { reason: 'old-shape documents reappeared' });
108
+ }
109
+ deps.telemetry?.backgroundDrift({ name: state._id });
110
+ deps.emit('background:drift', {
111
+ migration: state._id,
112
+ collection: spec.collection,
113
+ source: 'poll',
114
+ action,
115
+ });
116
+ deps.logger.warn(
117
+ `⚠ ${spec.collection}: old-shape documents appeared after ${state._id} completed — ` +
118
+ (action === 'reopened' ? 'reopened it' : 'an old release may still be writing'),
119
+ deps.fields({ background: state._id, collection: spec.collection, action }),
120
+ );
121
+ result.drift.push({ migration: state._id, collection: spec.collection, action });
122
+ }
123
+ return result;
124
+ }
125
+
126
+ module.exports = { probeOldShape, stillDirty, verify };
@@ -0,0 +1,375 @@
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 { backgroundLogs } = require('./migration-logger.js');
17
+ const { READ_OPTIONS } = require('./server-info.js');
18
+
19
+ /**
20
+ * Dry runs of a background migration — what it would do, on real documents,
21
+ * with nothing written. Works for a file that is not registered yet: the
22
+ * point is to look before `up`.
23
+ *
24
+ * - On a sample (`$sample`, or the `first` n by `_id`): each document before
25
+ * and after its transformation — the transformation alone.
26
+ * - With `validate`: the real write path (`applyBatch` — the guarded write,
27
+ * the diff, the side writes) inside the always-aborted sandbox, so what
28
+ * comes back is what the server would have stored, and what the validator
29
+ * or a unique index would have refused.
30
+ *
31
+ * Nothing here logs a document: they are the application's data.
32
+ */
33
+
34
+ const MAX_SAMPLE = 1000;
35
+
36
+ /** `value` as relaxed EJSON — what a person (or `--json`) can read */
37
+ const ejson = (value) => {
38
+ try {
39
+ return toRelaxedEjson(value);
40
+ } catch {
41
+ return { $unserializable: true };
42
+ }
43
+ };
44
+
45
+ function sampleSize({ sample, first }) {
46
+ const n = first ?? sample ?? 5;
47
+ if (!Number.isSafeInteger(n) || n < 1 || n > MAX_SAMPLE) {
48
+ throw new ConfigInvalidError(`The sample must be 1 to ${MAX_SAMPLE} documents`, { sample: n });
49
+ }
50
+ if (sample !== undefined && first !== undefined) {
51
+ throw new ConfigInvalidError('Take either a random sample or the first documents, not both');
52
+ }
53
+ return n;
54
+ }
55
+
56
+ /**
57
+ * `ctx.logger` in a dry run: the lines say `dryRun: true`, and nothing is
58
+ * emitted — a preview's logs are not the application's to keep.
59
+ */
60
+ const dryLogs = (logger) => backgroundLogs({ sink: logger, dryRun: true });
61
+
62
+ /** The job a dry run works with — no partition, no lease */
63
+ function dryJob(name, loaded, direction, logger) {
64
+ return {
65
+ name,
66
+ spec: loaded.spec,
67
+ fns: loaded.fns,
68
+ direction,
69
+ partitioner: idRangePartitioner,
70
+ generation: 0,
71
+ partitionId: 'dry-run',
72
+ match: matchOf(loaded.spec, direction),
73
+ logger,
74
+ logs: dryLogs(logger),
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Whether a collection is sharded, for the sandbox — asked only behind a
80
+ * mongos; a key that cannot be read is left to the server to judge.
81
+ */
82
+ function shardedCheck(deps) {
83
+ return async (name) =>
84
+ (await deps.topology()) === 'sharded' && Boolean(await deps.shardKeyOf?.(name));
85
+ }
86
+
87
+ /**
88
+ * Preview a declarative background migration on a sample. `deps`:
89
+ * `{ db, client, topology, forbidden, logger, shardKeyOf? }`; `loaded`: `{ spec, fns }`.
90
+ */
91
+ async function previewSample(deps, name, loaded, options = {}) {
92
+ const { spec } = loaded;
93
+ if (spec.mode !== 'declarative') {
94
+ throw new ConfigInvalidError(
95
+ `${name} is a step background migration — dry-run it by steps, not on a sample`,
96
+ { migration: name },
97
+ );
98
+ }
99
+ const direction = options.direction === 'revert' ? 'revert' : 'forward';
100
+ if (direction === 'revert' && !spec.reversible) {
101
+ throw new ConfigInvalidError(`${name} declares no revert`, { migration: name });
102
+ }
103
+ const n = sampleSize(options);
104
+ const job = dryJob(name, loaded, direction, deps.logger);
105
+ const collection = deps.db.collection(spec.collection);
106
+ const docs =
107
+ options.first !== undefined
108
+ ? await collection.find(job.match, { sort: { _id: 1 }, limit: n, ...READ_OPTIONS }).toArray()
109
+ : await collection
110
+ .aggregate([{ $match: job.match }, { $sample: { size: n } }], READ_OPTIONS)
111
+ .toArray();
112
+ const base = {
113
+ mode: 'declarative',
114
+ migration: name,
115
+ direction,
116
+ method: options.first !== undefined ? 'first' : 'sample',
117
+ requested: n,
118
+ found: docs.length,
119
+ };
120
+ if (!options.validate) {
121
+ const rows = await transformRows(job, docs);
122
+ return { ...base, ...tally(rows), documents: rows };
123
+ }
124
+ return {
125
+ ...base,
126
+ ...(await validateRows(deps, job, docs, { deadlineMs: deadlineOf(options) })),
127
+ };
128
+ }
129
+
130
+ /** Each document before, and after its transformation as it would be written */
131
+ async function transformRows(job, docs) {
132
+ const ctx = transformContext(job, { dryRun: true });
133
+ const transformed = await transformAll(job, docs, ctx);
134
+ const target = job.direction === 'revert' ? job.spec.from : job.spec.to;
135
+ const rows = [];
136
+ for (const { doc, next, error } of transformed) {
137
+ const row = { _id: ejson(doc._id), before: ejson(doc) };
138
+ if (error !== undefined) {
139
+ row.error = errorText(error);
140
+ } else {
141
+ try {
142
+ const update = stampedDiff(doc, next, job.spec, { to: target });
143
+ row.change = ejson(update);
144
+ row.after = ejson(applyLocally(doc, update));
145
+ } catch (shapeError) {
146
+ row.error = errorText(shapeError);
147
+ }
148
+ }
149
+ rows.push(row);
150
+ }
151
+ return rows;
152
+ }
153
+
154
+ /** `doc` with a stamped operator update applied, top-level only — a preview, not the server */
155
+ function applyLocally(doc, update) {
156
+ const out = cloneDocument(doc);
157
+ for (const [key, value] of Object.entries(update.$set ?? {})) out[key] = value;
158
+ for (const key of Object.keys(update.$unset ?? {})) delete out[key];
159
+ for (const [key, value] of Object.entries(update.$inc ?? {})) out[key] = (out[key] ?? 0) + value;
160
+ return out;
161
+ }
162
+
163
+ function tally(rows) {
164
+ let migrated = 0;
165
+ let failed = 0;
166
+ for (const row of rows) {
167
+ if (row.error === undefined) migrated += 1;
168
+ else failed += 1;
169
+ }
170
+ return { migrated, failed };
171
+ }
172
+
173
+ /**
174
+ * Each document through the real write path in the sandbox, one at a time.
175
+ * A write error aborts the sandbox's transaction on the server — so the rest
176
+ * go on in a fresh sandbox, one transaction per failure, not per document.
177
+ */
178
+ /** The longest a sandbox runs — under the server's 60-second transaction limit */
179
+ const MAX_DEADLINE_MS = 50_000;
180
+
181
+ /** `deadlineMs`, checked: 1 to 50 000 (the default) */
182
+ function deadlineOf(options) {
183
+ const ms = options.deadlineMs ?? MAX_DEADLINE_MS;
184
+ if (!Number.isSafeInteger(ms) || ms < 1 || ms > MAX_DEADLINE_MS) {
185
+ throw new ConfigInvalidError(`deadlineMs must be an integer from 1 to ${MAX_DEADLINE_MS}`, {
186
+ deadlineMs: ms,
187
+ });
188
+ }
189
+ return ms;
190
+ }
191
+
192
+ async function validateRows(deps, job, docs, { deadlineMs } = {}) {
193
+ const rows = new Map();
194
+ const ops = [];
195
+ const refusals = [];
196
+ const sideEffects = [];
197
+ let remaining = docs;
198
+ let attempts = 0;
199
+ while (remaining.length > 0) {
200
+ let done = 0;
201
+ const report = await runSandbox(
202
+ {
203
+ client: deps.client,
204
+ db: deps.db,
205
+ forbidden: deps.forbidden,
206
+ topology: await deps.topology(),
207
+ isSharded: shardedCheck(deps),
208
+ ...(deadlineMs !== undefined ? { deadlineMs } : {}),
209
+ },
210
+ async (handles) => {
211
+ for (const doc of remaining) {
212
+ const result = await applyBatch(job, [doc], {
213
+ db: handles.db,
214
+ bare: true,
215
+ ctxExtra: {
216
+ db: handles.db,
217
+ client: handles.client,
218
+ session: handles.session,
219
+ dryRun: true,
220
+ },
221
+ });
222
+ const row = { _id: ejson(doc._id), before: ejson(doc) };
223
+ const [failure] = result.errors;
224
+ if (failure !== undefined) {
225
+ row.error = failure.error;
226
+ row.validation = 'failed';
227
+ } else if (result.migrated === 1) {
228
+ const stored = await handles.db
229
+ .collection(job.spec.collection)
230
+ .findOne({ _id: doc._id });
231
+ row.after = ejson(stored);
232
+ row.validation = 'ok';
233
+ } else {
234
+ row.validation = 'skipped';
235
+ }
236
+ rows.set(idKey(doc._id), row);
237
+ done += 1;
238
+ // A failed write ended the transaction: the rest go to a new sandbox.
239
+ if (failure?.reason === 'write') return;
240
+ }
241
+ },
242
+ );
243
+ attempts += report.attempts;
244
+ ops.push(...report.ops);
245
+ refusals.push(...report.refusals);
246
+ for (const document of report.documents) {
247
+ if (document.collection !== job.spec.collection) sideEffects.push(document);
248
+ }
249
+ const stopped =
250
+ report.error ??
251
+ (report.stoppedBy === 'deadline' ? 'the dry run reached its deadline' : undefined);
252
+ if (stopped !== undefined && done < remaining.length) {
253
+ // The sandbox stopped on something else (a refusal, the deadline): say so on the next row.
254
+ const doc = remaining[done];
255
+ rows.set(idKey(doc._id), {
256
+ _id: ejson(doc._id),
257
+ before: ejson(doc),
258
+ error: stopped,
259
+ validation: 'failed',
260
+ });
261
+ done += 1;
262
+ }
263
+ remaining = remaining.slice(Math.max(1, done));
264
+ }
265
+ const documents = docs.map((doc) => rows.get(idKey(doc._id)));
266
+ return {
267
+ validated: true,
268
+ aborted: true,
269
+ ...tally(documents),
270
+ documents,
271
+ ops,
272
+ refusals,
273
+ sideEffects,
274
+ attempts,
275
+ };
276
+ }
277
+
278
+ const MAX_STEPS = 50;
279
+
280
+ /**
281
+ * Dry-run a step migration: up to `steps` calls of `step` in one sandbox —
282
+ * one transaction, always aborted — each handed the checkpoint the one
283
+ * before returned, so a step sees what the earlier ones wrote. It starts
284
+ * from the checkpoint the background migration is at (`checkpoint`), or
285
+ * from none (`fromStart`, or not registered). No lock, no lease, no state.
286
+ */
287
+ async function previewSteps(deps, name, loaded, options = {}) {
288
+ const { spec, fns } = loaded;
289
+ const direction = options.direction === 'revert' ? 'revert' : 'forward';
290
+ const fn = direction === 'revert' ? fns.revertStep : fns.step;
291
+ if (typeof fn !== 'function') {
292
+ throw new ConfigInvalidError(`${name} declares no revertStep`, { migration: name });
293
+ }
294
+ if (options.validate || options.sample !== undefined || options.first !== undefined) {
295
+ throw new ConfigInvalidError(
296
+ `${name} is a step background migration — dry-run it by steps, not on a sample`,
297
+ { migration: name },
298
+ );
299
+ }
300
+ const steps = options.steps ?? 1;
301
+ if (!Number.isSafeInteger(steps) || steps < 1 || steps > MAX_STEPS) {
302
+ throw new ConfigInvalidError(`steps must be 1 to ${MAX_STEPS}`, { steps });
303
+ }
304
+ const job = {
305
+ name,
306
+ spec,
307
+ fns,
308
+ direction,
309
+ generation: 0,
310
+ partitionId: 'dry-run',
311
+ logger: deps.logger,
312
+ logs: dryLogs(deps.logger),
313
+ };
314
+ const log = [];
315
+ let stoppedBy = 'steps';
316
+ const deadlineMs = deadlineOf(options);
317
+ const report = await runSandbox(
318
+ {
319
+ client: deps.client,
320
+ db: deps.db,
321
+ forbidden: deps.forbidden,
322
+ topology: await deps.topology(),
323
+ isSharded: shardedCheck(deps),
324
+ deadlineMs,
325
+ ...(options.maxDocuments !== undefined ? { maxDocuments: options.maxDocuments } : {}),
326
+ },
327
+ async (handles) => {
328
+ let checkpoint = options.fromStart ? null : (options.checkpoint ?? null);
329
+ log.length = 0;
330
+ for (let step = 1; step <= steps; step++) {
331
+ handles.nextStep();
332
+ const entry = { step, checkpointIn: ejson(checkpoint) };
333
+ log.push(entry);
334
+ const ctx = {
335
+ ...buildStepContext(job, {
336
+ db: handles.db,
337
+ client: handles.client,
338
+ session: handles.session,
339
+ checkpoint,
340
+ deadline: Date.now() + deadlineMs,
341
+ dryRun: true,
342
+ }),
343
+ mongoose: handles.mongoose,
344
+ };
345
+ let result;
346
+ try {
347
+ result = readStepResult(await fn(ctx));
348
+ } catch (error) {
349
+ entry.error = errorText(error);
350
+ throw error;
351
+ }
352
+ entry.checkpointOut = ejson(result.checkpoint);
353
+ entry.done = result.done;
354
+ if (result.counters.processed !== undefined) entry.processed = result.counters.processed;
355
+ if (result.counters.migrated !== undefined) entry.migrated = result.counters.migrated;
356
+ if (result.done) {
357
+ stoppedBy = 'done';
358
+ return;
359
+ }
360
+ checkpoint = result.checkpoint;
361
+ }
362
+ },
363
+ );
364
+ const { value: _value, ...rest } = report;
365
+ return {
366
+ mode: 'step',
367
+ migration: name,
368
+ direction,
369
+ ...rest,
370
+ stoppedBy: report.stoppedBy ?? stoppedBy,
371
+ steps: log,
372
+ };
373
+ }
374
+
375
+ module.exports = { MAX_SAMPLE, MAX_STEPS, previewSample, previewSteps };