@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,542 @@
1
+ const { mapLimit } = require('../utils/concurrency.js');
2
+ const { sameValue } = require('../versioning/internal.js');
3
+ const {
4
+ MAX_TIME_EXPIRED,
5
+ SAMPLE_TIMEOUT_MS,
6
+ largestFirst,
7
+ sampleRequest,
8
+ targetPartitions,
9
+ } = require('./background-partition.js');
10
+ const { loadBson } = require('./bson-peer.js');
11
+ const { READ_OPTIONS } = require('./server-info.js');
12
+ const { shardedVersionIndexKey } = require('./versioning-spec.js');
13
+
14
+ /**
15
+ * The shard-aware partitioner: a sharded collection's documents split along
16
+ * its shard key — a partition per run of adjacent chunks on one shard, so a
17
+ * lane reads from (and, with targeted writes, writes to) one shard, and
18
+ * `shardConcurrency` caps the lanes per shard (`group` = the shard).
19
+ *
20
+ * Its scope is `{ kind: 'key-range', min, max }`: two shard-key documents,
21
+ * `min` inclusive, `max` exclusive, like a chunk's. A batch reads it through
22
+ * the version index of a sharded collection (`{ __v, …shard key, _id }`,
23
+ * converge's) with `min`/`max` — exact index bounds — plus, when both ends of
24
+ * the first key field are finite and of one BSON type, a predicate on it
25
+ * that lets the mongos target the owning shard — and, as a run ends at a
26
+ * chunk boundary, the next chunk's (ARCHITECTURE §6.8: `min`/`max` alone are
27
+ * broadcast, and a `$lt` is inclusive when the mongos picks shards). Writes
28
+ * carry the shard key, so each goes to one shard.
29
+ *
30
+ * A ranged key keeps a keyset cursor (the last index tuple read). A hashed
31
+ * key cannot — the hash of the last document is the server's to compute — so
32
+ * it drains: every batch starts at the range's start, rewritten documents
33
+ * leave it by themselves, and the few that stay (a conflict, a failed
34
+ * document) are excluded by id, up to MAX_STUCK; past that the partition is
35
+ * left to the next pass.
36
+ *
37
+ * Nothing here has to be exact, as with the `_id` partitioner: a gap is
38
+ * found by the final count, an overlap is kept apart by the optimistic guard.
39
+ */
40
+
41
+ /** Partitions a shard gets at most, whatever its lanes */
42
+ const PARTITIONS_PER_SHARD = 8;
43
+ /** Documents a draining partition steps over before it leaves them to the next pass */
44
+ const MAX_STUCK = 100;
45
+ /** Runs sampled at once while a plan is drawn (under the coordinator lock) */
46
+ const SAMPLE_CONCURRENCY = 4;
47
+ /** A run this many times larger than its sample is sampled collection-first */
48
+ const SAMPLE_FIRST_FACTOR = 20;
49
+
50
+ const HASH_MIN = -(2n ** 63n);
51
+ const HASH_END = 2n ** 63n;
52
+
53
+ const isMinKey = (value) => value?._bsontype === 'MinKey';
54
+ const isMaxKey = (value) => value?._bsontype === 'MaxKey';
55
+ const isBound = (value) => isMinKey(value) || isMaxKey(value);
56
+
57
+ /** A value's comparison class — two values of one class compare the way MQL's `$gte`/`$lt` do */
58
+ function classOf(value) {
59
+ if (value === null || value === undefined) return 'null';
60
+ switch (typeof value) {
61
+ case 'number':
62
+ case 'bigint':
63
+ return 'number';
64
+ case 'string':
65
+ return 'string';
66
+ case 'boolean':
67
+ return 'bool';
68
+ default:
69
+ break;
70
+ }
71
+ if (value instanceof Date) return 'date';
72
+ switch (value._bsontype) {
73
+ case 'Int32':
74
+ case 'Long':
75
+ case 'Double':
76
+ case 'Decimal128':
77
+ return 'number';
78
+ case 'ObjectId':
79
+ case 'ObjectID':
80
+ return 'objectId';
81
+ case 'Timestamp':
82
+ return 'timestamp';
83
+ default:
84
+ // Objects, arrays, binaries, MinKey/MaxKey: no predicate is drawn on them.
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ /** A value at a dotted path — the way an index reads it, a missing one as `null` */
90
+ function valueAt(doc, path) {
91
+ let value = doc;
92
+ for (const part of path.split('.')) {
93
+ if (value === null || typeof value !== 'object') return null;
94
+ value = value[part];
95
+ }
96
+ return value === undefined ? null : value;
97
+ }
98
+
99
+ /**
100
+ * The shard-key fields of a document as a write filter — next to the
101
+ * optimistic filter, they send the write to the one shard that owns the
102
+ * document (and keep it off orphans). `_id` is already in that filter.
103
+ */
104
+ function shardKeyFilter(key, doc) {
105
+ const filter = {};
106
+ for (const field of Object.keys(key)) if (field !== '_id') filter[field] = valueAt(doc, field);
107
+ return filter;
108
+ }
109
+
110
+ /**
111
+ * The shard-key guard: a transform that changes a document's shard key is a
112
+ * document error. Nothing else refuses it — with retryable writes the mongos
113
+ * moves the document (ARCHITECTURE §6.8) — and moving documents between
114
+ * shards is not what a background migration is for.
115
+ */
116
+ function shardKeyGuard(key, prev, next) {
117
+ const changed = [];
118
+ for (const field of Object.keys(key)) {
119
+ if (!sameKeyValue(valueAt(prev, field), valueAt(next, field))) changed.push(field);
120
+ }
121
+ if (changed.length === 0) return null;
122
+ return {
123
+ reason: 'shard-key-changed',
124
+ fields: changed,
125
+ message:
126
+ `the transform changes the shard key (${changed.join(', ')}) — change a shard key with an ` +
127
+ 'ordinary migration in a transaction, or with reshardCollection',
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Whether two shard-key values route the same: numbers by value whatever
133
+ * their type (a Long 5 is an int 5 to a chunk, and to a hash), the rest
134
+ * BSON-aware. A value it cannot prove equal counts as changed.
135
+ */
136
+ function sameKeyValue(a, b) {
137
+ if (classOf(a) === 'number' && classOf(b) === 'number') return numberText(a) === numberText(b);
138
+ return sameValue(a, b);
139
+ }
140
+
141
+ const numberText = (value) =>
142
+ typeof value === 'bigint' || value?._bsontype === 'Long' || value?._bsontype === 'Decimal128'
143
+ ? value.toString()
144
+ : String(Number(value));
145
+
146
+ /** Whether a plan's epoch is not the collection's any more — resharded, or its key refined */
147
+ function staleEpoch(planned, current) {
148
+ if (!planned || !current) return false;
149
+ if (bytesOf(planned.uuid) !== bytesOf(current.uuid)) return true;
150
+ return JSON.stringify(planned.key) !== JSON.stringify(current.key);
151
+ }
152
+
153
+ /** A uuid's bytes as hex — the same for a `Binary` and a `UUID` of them */
154
+ function bytesOf(uuid) {
155
+ return uuid?.buffer instanceof Uint8Array
156
+ ? Buffer.from(uuid.buffer).toString('hex')
157
+ : String(uuid);
158
+ }
159
+
160
+ /**
161
+ * The `_id` partitioner on a sharded collection whose key is known but whose
162
+ * version index does not carry it: reads stay broadcast, but writes are
163
+ * targeted and the guard holds.
164
+ */
165
+ function withShardKey(partitioner, key) {
166
+ return Object.freeze({
167
+ ...partitioner,
168
+ writeFilter: (doc) => shardKeyFilter(key, doc),
169
+ checkTransform: (prev, next) => shardKeyGuard(key, prev, next),
170
+ });
171
+ }
172
+
173
+ /** The hashed field of a shard key, if it has one */
174
+ function hashedFieldOf(key) {
175
+ for (const [field, value] of Object.entries(key)) if (value === 'hashed') return field;
176
+ return undefined;
177
+ }
178
+
179
+ /** A shard-key document with every field at MinKey (or MaxKey) */
180
+ function edge(fields, Bound) {
181
+ const doc = {};
182
+ for (const field of fields) doc[field] = new Bound();
183
+ return doc;
184
+ }
185
+
186
+ /** Runs: maximal sequences of adjacent chunks owned by one shard, in key order */
187
+ function runsOf(chunks) {
188
+ const runs = [];
189
+ for (const chunk of chunks) {
190
+ const last = runs[runs.length - 1];
191
+ if (last !== undefined && last.shard === chunk.shard) {
192
+ last.max = chunk.max;
193
+ last.chunks += 1;
194
+ } else {
195
+ runs.push({ min: chunk.min, max: chunk.max, shard: chunk.shard, chunks: 1 });
196
+ }
197
+ }
198
+ return runs;
199
+ }
200
+
201
+ /**
202
+ * At most `limit` runs: neighbours merged in even blocks. A block that spans
203
+ * shards has no group — its lane is capped by `maxParallel` alone.
204
+ */
205
+ function capRuns(runs, limit) {
206
+ if (runs.length <= limit) return runs;
207
+ const size = Math.ceil(runs.length / limit);
208
+ const merged = [];
209
+ for (let start = 0; start < runs.length; start += size) {
210
+ const end = Math.min(runs.length, start + size);
211
+ let shard = runs[start].shard;
212
+ let chunks = 0;
213
+ for (let i = start; i < end; i++) {
214
+ if (runs[i].shard !== shard) shard = undefined;
215
+ chunks += runs[i].chunks;
216
+ }
217
+ merged.push({ min: runs[start].min, max: runs[end - 1].max, shard, chunks });
218
+ }
219
+ return merged;
220
+ }
221
+
222
+ /** A hash bound as a BigInt — MinKey and MaxKey at the ends of the 64-bit space */
223
+ function hashAt(value) {
224
+ if (isMinKey(value)) return HASH_MIN;
225
+ if (isMaxKey(value)) return HASH_END;
226
+ if (typeof value === 'bigint') return value;
227
+ if (value?._bsontype === 'Long') return value.toBigInt();
228
+ return BigInt(Math.trunc(Number(value)));
229
+ }
230
+
231
+ /**
232
+ * Split a run whose first key field is hashed by arithmetic: `parts` equal
233
+ * slices of its hash range. The boundary documents carry MinKey after the
234
+ * hash, so each slice starts before every document of its first hash.
235
+ */
236
+ function splitHashed(run, fields, parts) {
237
+ const [first, ...rest] = fields;
238
+ const lo = hashAt(run.min[first]);
239
+ const hi = hashAt(run.max[first]);
240
+ if (hi - lo < BigInt(parts) * 2n) return [run];
241
+ const { Long, MinKey } = loadBson();
242
+ const step = (hi - lo) / BigInt(parts);
243
+ const slices = [];
244
+ let min = run.min;
245
+ for (let i = 1; i < parts; i++) {
246
+ const max = { [first]: Long.fromBigInt(lo + step * BigInt(i)), ...edge(rest, MinKey) };
247
+ slices.push({ ...run, min, max });
248
+ min = max;
249
+ }
250
+ slices.push({ ...run, min, max: run.max });
251
+ return slices;
252
+ }
253
+
254
+ /**
255
+ * Split a ranged run at quantiles of a sample of its shard-key tuples, sorted
256
+ * by the server. Only tuples whose first field lies strictly inside the run
257
+ * are sampled — each is then a valid boundary, whatever the other fields
258
+ * hold. A hashed field further in the key is sampled as its hash, the value
259
+ * the index orders by. A run far larger than its sample is sampled from the
260
+ * collection first (a random cursor, `share` being the run's part of it) and
261
+ * filtered after; a `$match` first would read every document of the run and
262
+ * sort them all at random. Resolves to `{ pieces, degraded? }` — the run
263
+ * whole when it cannot be split (bounds of two types, a sample too small),
264
+ * and `degraded: 'sample-timeout'` when the sample ran out of time.
265
+ */
266
+ async function splitSampled(collection, match, run, fields, key, parts, { total, share }) {
267
+ const [first] = fields;
268
+ const lo = run.min[first];
269
+ const hi = run.max[first];
270
+ const condition = {};
271
+ if (!isMinKey(lo)) condition.$gt = lo;
272
+ if (!isMaxKey(hi)) condition.$lt = hi;
273
+ if (!isBound(lo) && !isBound(hi) && classOf(lo) !== classOf(hi)) return { pieces: [run] };
274
+ const projection = { _id: 0 };
275
+ const sort = {};
276
+ for (const field of fields) {
277
+ projection[field] = key[field] === 'hashed' ? { $toHashedIndexKey: `$${field}` } : 1;
278
+ sort[field] = 1;
279
+ }
280
+ const filter = Object.keys(condition).length > 0 ? { [first]: condition } : {};
281
+ const inRun = { $and: [match, filter] };
282
+ const size = Math.min(10_000, 100 * parts);
283
+ const sampleFirst = total !== undefined && total * share > SAMPLE_FIRST_FACTOR * size;
284
+ const pipeline = sampleFirst
285
+ ? [{ $sample: { size: sampleRequest(size, share, total) } }, { $match: inRun }]
286
+ : [{ $match: inRun }, { $sample: { size } }];
287
+ let sample;
288
+ try {
289
+ sample = await collection
290
+ .aggregate([...pipeline, { $project: projection }, { $sort: sort }], {
291
+ maxTimeMS: SAMPLE_TIMEOUT_MS,
292
+ allowDiskUse: true,
293
+ ...READ_OPTIONS,
294
+ promoteLongs: false,
295
+ })
296
+ .toArray();
297
+ } catch (error) {
298
+ if (error?.code === MAX_TIME_EXPIRED) return { pieces: [run], degraded: 'sample-timeout' };
299
+ throw error;
300
+ }
301
+ if (sample.length < parts) return { pieces: [run] };
302
+ const slices = [];
303
+ let min = run.min;
304
+ let previous;
305
+ for (let i = 1; i < parts; i++) {
306
+ const at = sample[Math.floor((i * sample.length) / parts)];
307
+ const max = {};
308
+ for (const field of fields) max[field] = valueAt(at, field);
309
+ if (previous !== undefined && sameTuple(previous, max, fields)) continue;
310
+ slices.push({ ...run, min, max });
311
+ min = max;
312
+ previous = max;
313
+ }
314
+ slices.push({ ...run, min, max: run.max });
315
+ return { pieces: slices };
316
+ }
317
+
318
+ function sameTuple(a, b, fields) {
319
+ for (const field of fields) if (!sameValue(a[field], b[field])) return false;
320
+ return true;
321
+ }
322
+
323
+ /**
324
+ * The shard-aware partitioner of one sharded collection. `options`:
325
+ * `{ key, field, source, readChunks }` — the shard key, the version field,
326
+ * the version a batch reads (`0` reads the missing-or-null region and then
327
+ * the `0` one), and how to read the chunks (`undefined` when config may not
328
+ * be read: the plan then samples the whole key space, ungrouped).
329
+ */
330
+ function createShardPartitioner({ key, field, source, readChunks, epoch }) {
331
+ const fields = Object.keys(key);
332
+ const hashed = hashedFieldOf(key);
333
+ const drain = hashed !== undefined;
334
+ const indexKey = shardedVersionIndexKey({ field }, key);
335
+ const trailingId = '_id' in indexKey && !('_id' in key);
336
+ const regions = source === 0 ? [null, 0] : [source];
337
+
338
+ /** An index bound: the version region, a shard-key document, then `_id` */
339
+ function bound(region, tuple, id) {
340
+ const doc = { [field]: region };
341
+ for (const name of fields) doc[name] = tuple[name];
342
+ if (trailingId) doc._id = id;
343
+ return doc;
344
+ }
345
+
346
+ /**
347
+ * A predicate on the first key field that targets the owning shard — only
348
+ * when both ends are finite and of one class: a one-sided or mixed-type
349
+ * range would leave out every value MQL compares differently.
350
+ */
351
+ function target(scope) {
352
+ const [first] = fields;
353
+ if (first === hashed) return undefined;
354
+ const lo = scope.min[first];
355
+ const hi = scope.max[first];
356
+ if (isBound(lo) || isBound(hi)) return undefined;
357
+ const kind = classOf(lo);
358
+ if (kind === undefined || kind !== classOf(hi)) return undefined;
359
+ // The range ends before `max`: before its first field's value too, when
360
+ // the rest of `max` is MinKey — `$lte` would draw in the next chunk's shard.
361
+ let open = true;
362
+ for (let i = 1; i < fields.length; i++) if (!isMinKey(scope.max[fields[i]])) open = false;
363
+ return { [first]: open ? { $gte: lo, $lt: hi } : { $gte: lo, $lte: hi } };
364
+ }
365
+
366
+ async function plan({ collection, match, hint, maxParallel, settings, shardConcurrency = 1 }) {
367
+ const aim = targetPartitions(settings, maxParallel);
368
+ const cap = aim * settings.minPartitionDocs;
369
+ const count = await collection.countDocuments(match, {
370
+ limit: cap,
371
+ ...(hint ? { hint } : {}),
372
+ ...READ_OPTIONS,
373
+ });
374
+ const planned = epoch ? { uuid: epoch.uuid, key } : null;
375
+ if (count === 0) return { epoch: planned, method: 'empty', estimate: 0, partitions: [] };
376
+ const { MaxKey, MinKey } = loadBson();
377
+ const chunks = await readChunks();
378
+ const grouped = chunks !== undefined && chunks.length > 0;
379
+ const allRuns = grouped ? runsOf(chunks) : undefined;
380
+ const runs = grouped
381
+ ? capRuns(allRuns, settings.maxPartitions)
382
+ : [{ min: edge(fields, MinKey), max: edge(fields, MaxKey), shard: undefined, chunks: 1 }];
383
+ // More runs than partitions allowed: blocks were merged across shards, and
384
+ // a merged block has no shard to cap its lanes by.
385
+ let ungrouped = false;
386
+ if (grouped && runs.length < allRuns.length) {
387
+ for (const run of runs) if (run.shard === undefined) ungrouped = true;
388
+ }
389
+ // A few partitions per lane: per shard when the lanes are capped per shard.
390
+ const perShard = grouped
391
+ ? Math.min(PARTITIONS_PER_SHARD, settings.overPartition * shardConcurrency, aim)
392
+ : aim;
393
+ const runsPerShard = new Map();
394
+ let totalChunks = 0;
395
+ for (const run of runs) {
396
+ runsPerShard.set(run.shard, (runsPerShard.get(run.shard) ?? 0) + 1);
397
+ totalChunks += run.chunks;
398
+ }
399
+ const budget = Math.max(runs.length, settings.maxPartitions);
400
+ const wanted = Math.max(1, Math.min(aim, Math.ceil(count / settings.minPartitionDocs)));
401
+ // The collection's size, for a run that is sampled collection-first.
402
+ const total =
403
+ typeof collection.estimatedDocumentCount === 'function'
404
+ ? await collection.estimatedDocumentCount().catch(() => undefined)
405
+ : undefined;
406
+ // Runs are sampled a few at a time: one after another, a plan of many runs
407
+ // would hold the coordinator lock for a sample timeout per run.
408
+ const split = await mapLimit(runs, SAMPLE_CONCURRENCY, async (run) => {
409
+ const parts = Math.min(
410
+ Math.ceil(perShard / runsPerShard.get(run.shard)),
411
+ Math.max(1, Math.floor(budget / runs.length)),
412
+ wanted,
413
+ );
414
+ if (parts <= 1) return { pieces: [run] };
415
+ if (fields[0] === hashed) return { pieces: splitHashed(run, fields, parts) };
416
+ return splitSampled(collection, match, run, fields, key, parts, {
417
+ total,
418
+ share: run.chunks / totalChunks,
419
+ });
420
+ });
421
+ const slices = [];
422
+ let timedOut = false;
423
+ for (let i = 0; i < runs.length; i++) {
424
+ const run = runs[i];
425
+ const { pieces, degraded } = split[i];
426
+ if (degraded !== undefined) timedOut = true;
427
+ for (const piece of pieces) {
428
+ slices.push({
429
+ scope: { kind: 'key-range', min: piece.min, max: piece.max },
430
+ ...(piece.shard !== undefined ? { group: piece.shard } : {}),
431
+ estimate: Math.round((count * run.chunks) / totalChunks / pieces.length),
432
+ });
433
+ }
434
+ }
435
+ const degraded = timedOut ? 'sample-timeout' : ungrouped ? 'ungrouped' : undefined;
436
+ return {
437
+ epoch: planned,
438
+ method: grouped ? 'chunks' : 'sampled',
439
+ estimate: count,
440
+ // The count stopped at its limit: there are at least that many.
441
+ ...(count >= cap ? { atLeast: true } : {}),
442
+ ...(degraded !== undefined ? { degraded } : {}),
443
+ partitions: largestFirst(slices),
444
+ };
445
+ }
446
+
447
+ /** The batch query of a key range: its index bounds, the targeting predicate, the cursor */
448
+ function batchQuery(scope, cursor, { limit, match, hint }) {
449
+ const { MinKey } = loadBson();
450
+ const region = regions[cursor?.region ?? 0];
451
+ const conditions = [match];
452
+ const targeting = target(scope);
453
+ if (targeting) conditions.push(targeting);
454
+ let min = bound(region, scope.min, new MinKey());
455
+ if (cursor?.tuple !== undefined) {
456
+ // Inclusive: the last tuple read is the start — and its document, if it
457
+ // is still there, is stepped over by id.
458
+ min = bound(region, cursor.tuple, cursor.tuple._id);
459
+ conditions.push({ _id: { $ne: cursor.tuple._id } });
460
+ }
461
+ if (cursor?.stuck?.length > 0) conditions.push({ _id: { $nin: cursor.stuck } });
462
+ return {
463
+ filter: { $and: conditions },
464
+ options: {
465
+ hint: hint ?? indexKey,
466
+ min,
467
+ max: bound(region, scope.max, new MinKey()),
468
+ limit,
469
+ // Through a mongos, several shards answer: sorted, their results merge
470
+ // in index order — which a keyset needs. A hashed key cannot be sorted on.
471
+ ...(drain ? {} : { sort: sortOf(indexKey) }),
472
+ ...READ_OPTIONS,
473
+ },
474
+ };
475
+ }
476
+
477
+ /**
478
+ * The cursor after a batch, or `null` once the range is done. `left`: the
479
+ * ids of documents the batch read but did not rewrite — a draining range
480
+ * steps over them from now on.
481
+ */
482
+ function advance(cursor, docs, { limit, left = [] }) {
483
+ const position = cursor?.region ?? 0;
484
+ if (docs.length < limit) {
485
+ return position + 1 < regions.length ? { region: position + 1 } : null;
486
+ }
487
+ return past(cursor, docs, { left });
488
+ }
489
+
490
+ /** The cursor just past `docs` — where a batch cut short (or a document stepped over) ends */
491
+ function past(cursor, docs, { left } = {}) {
492
+ const position = cursor?.region ?? 0;
493
+ if (drain) {
494
+ const stuck = [...(cursor?.stuck ?? []), ...(left ?? docs.map((doc) => doc._id))];
495
+ if (stuck.length > MAX_STUCK) {
496
+ return position + 1 < regions.length ? { region: position + 1 } : null;
497
+ }
498
+ return { region: position, stuck };
499
+ }
500
+ const last = docs[docs.length - 1];
501
+ const tuple = { _id: last._id };
502
+ for (const name of fields) tuple[name] = valueAt(last, name);
503
+ return { region: position, tuple };
504
+ }
505
+
506
+ return Object.freeze({
507
+ id: 'shard',
508
+ key,
509
+ indexKey,
510
+ plan,
511
+ batchQuery,
512
+ advance,
513
+ past,
514
+ writeFilter: (doc) => shardKeyFilter(key, doc),
515
+ checkTransform: (prev, next) => shardKeyGuard(key, prev, next),
516
+ /** A plan made for another epoch: resharded, or its key refined since */
517
+ stale: (planned) => staleEpoch(planned, epoch ? { uuid: epoch.uuid, key } : undefined),
518
+ });
519
+ }
520
+
521
+ /** A sort on an index key — every field ascending as the index stores it */
522
+ function sortOf(indexKey) {
523
+ const sort = {};
524
+ for (const [name, value] of Object.entries(indexKey)) sort[name] = value === -1 ? -1 : 1;
525
+ return sort;
526
+ }
527
+
528
+ module.exports = {
529
+ MAX_STUCK,
530
+ PARTITIONS_PER_SHARD,
531
+ capRuns,
532
+ classOf,
533
+ createShardPartitioner,
534
+ hashAt,
535
+ runsOf,
536
+ shardKeyFilter,
537
+ shardKeyGuard,
538
+ staleEpoch,
539
+ withShardKey,
540
+ splitHashed,
541
+ valueAt,
542
+ };