@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
package/src/cli/table.js CHANGED
@@ -1,3 +1,5 @@
1
+ const { TARGET_LABELS, isDestructive } = require('../core/converge-plan.js');
2
+ const { searchBuildState } = require('../core/search-index-spec.js');
1
3
  const { createColors, stripAnsi } = require('../utils/colors.js');
2
4
  const { formatDateTime } = require('../utils/date.js');
3
5
  // Shared with the logger and spinner — cell values come from the changelog and
@@ -270,12 +272,43 @@ function convergeActionCell(colors, action) {
270
272
  }
271
273
  }
272
274
 
273
- /** The detail column: what differs, or why the row is what it is */
275
+ /** Where the server is with a search index, when it is not simply serving it */
276
+ function searchBuildDetail(build) {
277
+ if (build === undefined) return '';
278
+ switch (searchBuildState(build)) {
279
+ case 'serving':
280
+ return '';
281
+ case 'failed':
282
+ return `FAILED${build.message ? `: ${build.message}` : ''}`;
283
+ case 'updating':
284
+ return 'updating';
285
+ case 'stale':
286
+ return 'STALE — not replicating';
287
+ default:
288
+ return build.status;
289
+ }
290
+ }
291
+
292
+ /** Whether a search index row is one worth showing even when nothing changes: not serving yet */
293
+ function searchNotServing(action) {
294
+ return action.target === 'searchIndex' && searchBuildDetail(action.build) !== '';
295
+ }
296
+
297
+ /** The detail column: what differs, or why the row is what it is — and a search index's build */
274
298
  function convergeDetail(action) {
275
299
  if (action.reason === 'name' && action.liveName !== undefined) {
276
300
  return `renamed from "${action.liveName}"`;
277
301
  }
278
- return action.reason ?? '';
302
+ if (action.target !== 'searchIndex') return action.reason ?? '';
303
+ const parts = [];
304
+ const type = action.to?.type ?? action.from?.type;
305
+ if (type === 'vectorSearch' && (action.action === 'create' || action.action === 'modify')) {
306
+ parts.push('vectorSearch');
307
+ }
308
+ if (action.reason) parts.push(action.reason);
309
+ const build = searchBuildDetail(action.build);
310
+ if (build) parts.push(build);
311
+ return parts.join(' · ');
279
312
  }
280
313
 
281
314
  /**
@@ -286,22 +319,32 @@ function convergeDetail(action) {
286
319
  function renderConvergeTable(result, { all = false } = {}) {
287
320
  const colors = palette();
288
321
  const cells = [];
289
- const counts = { change: 0, destructive: 0, conflict: 0, keep: 0, unchanged: 0, touched: 0 };
322
+ const counts = {
323
+ change: 0,
324
+ destructive: 0,
325
+ conflict: 0,
326
+ keep: 0,
327
+ searchKeep: 0,
328
+ skip: 0,
329
+ unchanged: 0,
330
+ touched: 0,
331
+ };
290
332
  for (const collection of result.collections) {
291
333
  let changes = 0;
292
334
  for (const action of collection.actions) {
293
335
  if (action.action === 'unchanged') counts.unchanged += 1;
336
+ else if (action.action === 'keep' && action.target === 'searchIndex') counts.searchKeep += 1;
294
337
  else if (action.action === 'keep') counts.keep += 1;
295
338
  else if (action.action === 'conflict') counts.conflict += 1;
339
+ else if (action.action === 'skip') counts.skip += 1;
296
340
  else changes += 1;
297
- if (action.target === 'index' && (action.action === 'drop' || action.action === 'recreate')) {
298
- counts.destructive += 1;
299
- }
300
- if (action.action === 'unchanged' && !all) continue;
341
+ if (isDestructive(action)) counts.destructive += 1;
342
+ if (action.action === 'unchanged' && !all && !searchNotServing(action)) continue;
343
+ const named = action.target === 'index' || action.target === 'searchIndex';
301
344
  cells.push([
302
345
  truncate(sanitize(collection.name), MAX_NAME_WIDTH),
303
- action.target,
304
- action.target === 'index' ? truncate(sanitize(action.name), MAX_NAME_WIDTH) : '',
346
+ TARGET_LABELS[action.target] ?? action.target,
347
+ named ? truncate(sanitize(action.name), MAX_NAME_WIDTH) : '',
305
348
  convergeActionCell(colors, action.action),
306
349
  truncate(sanitize(convergeDetail(action)), MAX_DETAIL_WIDTH),
307
350
  ]);
@@ -323,6 +366,22 @@ function renderConvergeTable(result, { all = false } = {}) {
323
366
  if (counts.destructive > 0) parts.push(`${counts.destructive} drop/rebuild`);
324
367
  if (counts.conflict > 0) parts.push(colors.red(`${counts.conflict} conflict(s)`));
325
368
  if (counts.keep > 0) parts.push(`${counts.keep} undeclared index(es) kept`);
369
+ if (counts.searchKeep > 0) parts.push(`${counts.searchKeep} undeclared search index(es) kept`);
370
+ if (counts.skip > 0) {
371
+ parts.push(colors.yellow(`${counts.skip} search index(es) skipped — Search unavailable`));
372
+ }
373
+ let building = 0;
374
+ let stale = 0;
375
+ let failed = 0;
376
+ for (const index of result.search?.notReady ?? []) {
377
+ const state = searchBuildState(index);
378
+ if (state === 'failed') failed += 1;
379
+ else if (state === 'stale') stale += 1;
380
+ else building += 1;
381
+ }
382
+ if (building > 0) parts.push(`${building} search index(es) building`);
383
+ if (stale > 0) parts.push(colors.yellow(`${stale} search index(es) stale`));
384
+ if (failed > 0) parts.push(colors.red(`${failed} search index(es) failed`));
326
385
  if (counts.unchanged > 0 && !all) parts.push(`${counts.unchanged} unchanged`);
327
386
  const line = parts.join(' · ');
328
387
  if (cells.length === 0) return line;
package/src/core/audit.js CHANGED
@@ -1,5 +1,9 @@
1
+ const { mapLimit } = require('../utils/concurrency.js');
1
2
  const { errorText } = require('../utils/error.js');
3
+ const { SEARCH_UNAVAILABLE_HINT, listSearchIndexes, probeSearch } = require('./converge-search.js');
2
4
  const { toLockInfo } = require('./lock.js');
5
+ const { normalizeLiveSearchIndex, searchBuildState } = require('./search-index-spec.js');
6
+ const { READ_OPTIONS, readServer } = require('./server-info.js');
3
7
 
4
8
  /** Changelog indexes ensureIndexes() creates — audit warns when any is absent */
5
9
  const EXPECTED_INDEXES = [
@@ -12,14 +16,16 @@ const EXPECTED_INDEXES = [
12
16
 
13
17
  /**
14
18
  * Read-only health check of the setup: configuration, connectivity,
15
- * transaction support, indexes, lock state and checksum drift.
19
+ * transaction support, indexes, lock state, checksum drift — and Atlas Search,
20
+ * when declared collections hold search indexes.
16
21
  *
17
22
  * Fixes nothing — it reports, so an operator can see in one command why a
18
23
  * migration would fail before running one. Every check is independent: a
19
24
  * failure in one is recorded and the rest still run.
20
25
  *
21
26
  * Pure orchestration over capabilities the MigratorKit injects (`deps`), so it
22
- * needs none of the kit's private state.
27
+ * needs none of the kit's private state. `deps.definitions()` (optional) gives
28
+ * the declared collections, for the search check.
23
29
  */
24
30
  async function runAudit(deps) {
25
31
  const checks = [];
@@ -133,7 +139,20 @@ async function runAudit(deps) {
133
139
  record('checksums', 'warn', `Could not read status: ${errorText(error)}`);
134
140
  }
135
141
 
136
- // 7. Runtime. TypeScript migrations need a runtime that can strip types.
142
+ // 7. Search — only where declared collections hold search indexes.
143
+ await auditSearch(deps, db, config, record);
144
+
145
+ // 8. Background migrations — only where any is registered.
146
+ if (typeof deps.background === 'function') {
147
+ try {
148
+ const finding = await deps.background();
149
+ if (finding !== null) record('background', finding.status, finding.detail);
150
+ } catch (error) {
151
+ record('background', 'warn', `Could not check background migrations: ${errorText(error)}`);
152
+ }
153
+ }
154
+
155
+ // 9. Runtime. TypeScript migrations need a runtime that can strip types.
137
156
  // Feature detection instead of version parsing: it also catches a run
138
157
  // under `--no-experimental-strip-types` on an otherwise capable Node.
139
158
  const nodeVersion = process.versions.node;
@@ -152,6 +171,82 @@ async function runAudit(deps) {
152
171
  return auditReport(checks);
153
172
  }
154
173
 
174
+ /** Search index lists the audit reads at once */
175
+ const AUDIT_READ_CONCURRENCY = 8;
176
+
177
+ /**
178
+ * Whether the server has the Atlas Search that declared search indexes need,
179
+ * and whether any of them failed to build. Records nothing when none is
180
+ * declared — or when the definitions do not load: converge reports that
181
+ * itself, with every issue.
182
+ */
183
+ async function auditSearch(deps, db, config, record) {
184
+ if (typeof deps.definitions !== 'function') return;
185
+ let definitions;
186
+ try {
187
+ definitions = await deps.definitions();
188
+ } catch {
189
+ return;
190
+ }
191
+ // One pass: the collections that declare search indexes, the names each
192
+ // declares, and how many there are in all.
193
+ const declaring = [];
194
+ let declared = 0;
195
+ for (const definition of definitions) {
196
+ if (definition.searchIndexes === undefined) continue;
197
+ const names = new Set();
198
+ for (const index of definition.searchIndexes) names.add(index.name);
199
+ declaring.push({ definition, names });
200
+ declared += definition.searchIndexes.length;
201
+ }
202
+ if (declaring.length === 0) return;
203
+ const counted = `${declared} search index(es) declared in ${declaring.length} collection(s)`;
204
+ try {
205
+ const probe = await probeSearch(db, await readServer(db), {
206
+ collection: declaring[0].definition.name,
207
+ readOptions: READ_OPTIONS,
208
+ });
209
+ if (!probe.available) {
210
+ const skip = config.onSearchUnavailable === 'skip';
211
+ record(
212
+ 'search',
213
+ skip ? 'warn' : 'fail',
214
+ `Not available on this server (${probe.reason}) — ${counted}; ` +
215
+ (skip
216
+ ? "converge skips them (onSearchUnavailable: 'skip')"
217
+ : `converge refuses them: ${SEARCH_UNAVAILABLE_HINT}`),
218
+ );
219
+ return;
220
+ }
221
+ const failed = [];
222
+ const stale = [];
223
+ // One list per collection, a few at a time; reported in declaration order.
224
+ const lists = await mapLimit(declaring, AUDIT_READ_CONCURRENCY, ({ definition }) =>
225
+ listSearchIndexes(db, definition.name, READ_OPTIONS),
226
+ );
227
+ for (const [position, { definition, names }] of declaring.entries()) {
228
+ for (const raw of lists[position]) {
229
+ const index = normalizeLiveSearchIndex(raw);
230
+ if (!names.has(index.name)) continue;
231
+ const state = searchBuildState(index);
232
+ const label = `${definition.name}.${index.name}${index.message ? ` (${index.message})` : ''}`;
233
+ if (state === 'failed') failed.push(label);
234
+ else if (state === 'stale') stale.push(label);
235
+ }
236
+ }
237
+ if (failed.length > 0 || stale.length > 0) {
238
+ const parts = [];
239
+ if (failed.length > 0) parts.push(`failed to build: ${failed.join(', ')}`);
240
+ if (stale.length > 0) parts.push(`stale (not replicating): ${stale.join(', ')}`);
241
+ record('search', 'warn', `Available — ${parts.join('; ')}`);
242
+ } else {
243
+ record('search', 'pass', `Available — ${counted}`);
244
+ }
245
+ } catch (error) {
246
+ record('search', 'warn', `Could not check Atlas Search: ${errorText(error)}`);
247
+ }
248
+ }
249
+
155
250
  /** Roll individual checks up into the report shape (one pass, both counters) */
156
251
  function auditReport(checks) {
157
252
  let failed = 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 };