@alexify/migronaut 2.2.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 (64) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +33 -2
  3. package/bullmq.d.ts +449 -6
  4. package/index.d.ts +1010 -9
  5. package/migronaut.schema.json +93 -1
  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 +128 -14
  11. package/src/bullmq/producer.js +185 -13
  12. package/src/bullmq/service.js +480 -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 +366 -0
  21. package/src/core/background-engine.js +818 -0
  22. package/src/core/background-kit.js +425 -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 +605 -0
  33. package/src/core/background.js +1121 -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/migrator.js +904 -12
  42. package/src/core/options.js +16 -0
  43. package/src/core/run.js +26 -12
  44. package/src/core/runner.js +1 -1
  45. package/src/core/server-info.js +9 -2
  46. package/src/core/shard-info.js +76 -0
  47. package/src/core/versioning-spec.js +181 -0
  48. package/src/errors/index.js +88 -0
  49. package/src/index.js +16 -0
  50. package/src/utils/error.js +11 -2
  51. package/src/utils/loader.js +77 -9
  52. package/src/utils/migration-name.js +33 -1
  53. package/src/utils/telemetry.js +107 -0
  54. package/src/utils/template.js +62 -1
  55. package/src/versioning/config.js +155 -0
  56. package/src/versioning/document.js +326 -0
  57. package/src/versioning/index.js +50 -0
  58. package/src/versioning/internal.js +279 -0
  59. package/src/versioning/mongoose.js +151 -0
  60. package/src/versioning/occ.js +318 -0
  61. package/src/versioning/registry.js +187 -0
  62. package/src/versioning/upcaster.js +213 -0
  63. package/versioning.d.ts +666 -0
  64. package/versioning.js +1 -0
@@ -0,0 +1,269 @@
1
+ const { errorText } = require('../utils/error.js');
2
+ const { isUnauthorized } = require('./shard-info.js');
3
+
4
+ /**
5
+ * Pacing a background migration: it rewrites a production collection while
6
+ * the application uses it, so it must yield to it — before every batch:
7
+ *
8
+ * - the author's pause between batches (`pauseMs`);
9
+ * - the author's own `throttle(ctx)` hook (a returned number is extra pause);
10
+ * - replication lag: on a replica set, wait while a secondary that counts
11
+ * (not hidden, not delayed) lags more than `maxReplicationLagMs` — the
12
+ * status read at most every 5 s, and switched off quietly where it cannot
13
+ * be read (standalone, mongos) or with one warning where it may not be
14
+ * (no `clusterMonitor`).
15
+ *
16
+ * The latency-driven batch sizing (AIMD) lives here too.
17
+ * Clock and sleep are injected, so every rule is tested without waiting.
18
+ */
19
+
20
+ /** How often the replication status is read, at most */
21
+ const LAG_CHECK_INTERVAL_MS = 5_000;
22
+
23
+ /** How often the replica set config (hidden and delayed members) is read, at most */
24
+ const CONFIG_CHECK_INTERVAL_MS = 60_000;
25
+
26
+ /** How long to wait between two looks at a lag that is too high */
27
+ const LAG_WAIT_MS = 1_000;
28
+
29
+ /** Errors that mean "no replication status here" — not a replica set member */
30
+ const NO_REPLICATION = new Set([
31
+ 59, // CommandNotFound (mongos)
32
+ 76, // NoReplicationEnabled (standalone)
33
+ 115, // CommandNotSupported
34
+ ]);
35
+
36
+ /** `ms`, jittered down to half of it — so processes that start together drift apart */
37
+ const jitter = (ms) => Math.round(ms * (0.5 + Math.random() / 2));
38
+
39
+ /** Sleep `ms`, cut short (rejecting with the reason) when `signal` aborts */
40
+ function sleep(ms, signal) {
41
+ return new Promise((resolve, reject) => {
42
+ if (signal?.aborted) {
43
+ reject(signal.reason);
44
+ return;
45
+ }
46
+ if (ms <= 0) {
47
+ resolve();
48
+ return;
49
+ }
50
+ const onAbort = () => {
51
+ clearTimeout(timer);
52
+ reject(signal.reason);
53
+ };
54
+ const timer = setTimeout(() => {
55
+ signal?.removeEventListener('abort', onAbort);
56
+ resolve();
57
+ }, ms);
58
+ signal?.addEventListener('abort', onAbort, { once: true });
59
+ });
60
+ }
61
+
62
+ /**
63
+ * The worst lag of a secondary that counts, in ms — or 0. Hidden and delayed
64
+ * members are left out: a delayed one lags on purpose, and a hidden one
65
+ * serves no reads.
66
+ */
67
+ function replicationLagMs(status, excluded) {
68
+ const members = Array.isArray(status?.members) ? status.members : [];
69
+ let primary;
70
+ for (const member of members) {
71
+ if (member.stateStr === 'PRIMARY') primary = member;
72
+ }
73
+ if (primary?.optimeDate === undefined) return 0;
74
+ const primaryTime = new Date(primary.optimeDate).getTime();
75
+ let worst = 0;
76
+ for (const member of members) {
77
+ if (member.stateStr !== 'SECONDARY' || excluded.has(member.name)) continue;
78
+ if (member.optimeDate === undefined) continue;
79
+ const lag = primaryTime - new Date(member.optimeDate).getTime();
80
+ if (lag > worst) worst = lag;
81
+ }
82
+ return worst;
83
+ }
84
+
85
+ /** Hidden or delayed member hosts, from the replica set config */
86
+ function excludedMembers(config) {
87
+ const hosts = new Set();
88
+ for (const member of config?.config?.members ?? []) {
89
+ const delay = member.secondaryDelaySecs ?? member.slaveDelay ?? 0;
90
+ if (member.hidden === true || Number(delay) > 0) hosts.add(member.host);
91
+ }
92
+ return hosts;
93
+ }
94
+
95
+ /**
96
+ * A throttle for one lane. `options`: `{ spec, db, logger, name, now?,
97
+ * sleep?, signal? }`. `beforeBatch(ctx)` waits as the rules say; it rejects
98
+ * only when `signal` aborts.
99
+ */
100
+ function createThrottle({ spec, db, logger, name, now = Date.now, wait = sleep, warned }) {
101
+ const maxLag = spec.maxReplicationLagMs;
102
+ let lagEnabled = maxLag !== false && typeof db?.admin === 'function';
103
+ let lastLagCheck = -Infinity;
104
+ let lastConfigCheck = -Infinity;
105
+ let excluded = new Set();
106
+ let first = true;
107
+ // Set when the last wait was for a lagging secondary — an overload signal.
108
+ let lagged = false;
109
+ // One warning per process for a lag that cannot be read for lack of rights.
110
+ const warnings = warned ?? new Set();
111
+
112
+ async function currentLag() {
113
+ const admin = db.admin();
114
+ if (now() - lastConfigCheck >= CONFIG_CHECK_INTERVAL_MS) {
115
+ lastConfigCheck = now();
116
+ try {
117
+ excluded = excludedMembers(await admin.command({ replSetGetConfig: 1 }));
118
+ } catch {
119
+ // Without the config every secondary counts — the cautious reading.
120
+ }
121
+ }
122
+ return replicationLagMs(await admin.command({ replSetGetStatus: 1 }), excluded);
123
+ }
124
+
125
+ async function waitForLag(signal) {
126
+ if (!lagEnabled || now() - lastLagCheck < LAG_CHECK_INTERVAL_MS) return;
127
+ for (;;) {
128
+ lastLagCheck = now();
129
+ let lag;
130
+ try {
131
+ lag = await currentLag();
132
+ } catch (error) {
133
+ lagEnabled = false;
134
+ if (isUnauthorized(error) && !warnings.has('lag')) {
135
+ warnings.add('lag');
136
+ logger.warn(
137
+ '⚠ Background migrations cannot read the replication lag (replSetGetStatus needs ' +
138
+ 'the clusterMonitor role) — they no longer wait for secondaries',
139
+ { background: name, error: errorText(error) },
140
+ );
141
+ } else if (!NO_REPLICATION.has(error?.code)) {
142
+ logger.debug(`Replication lag unreadable — not waiting for it: ${errorText(error)}`, {
143
+ background: name,
144
+ });
145
+ }
146
+ return;
147
+ }
148
+ if (lag <= maxLag) return;
149
+ lagged = true;
150
+ logger.debug(`Replication lag ${lag}ms > ${maxLag}ms — waiting`, {
151
+ background: name,
152
+ lagMs: lag,
153
+ });
154
+ await wait(LAG_WAIT_MS, signal);
155
+ }
156
+ }
157
+
158
+ return {
159
+ /** Whether the last `beforeBatch` waited for a lagging secondary — read once */
160
+ takeLagged() {
161
+ const was = lagged;
162
+ lagged = false;
163
+ return was;
164
+ },
165
+ /** Everything a lane waits for before reading its next batch */
166
+ async beforeBatch({ signal, generation, partition, batchSize, extraPauseMs = 0 }) {
167
+ const pause = (first ? 0 : spec.pauseMs) + extraPauseMs;
168
+ if (pause > 0) await wait(pause, signal);
169
+ first = false;
170
+ if (typeof spec.throttle === 'function') {
171
+ const extra = await spec.throttle({
172
+ name,
173
+ ...(spec.collection !== undefined ? { collection: spec.collection } : {}),
174
+ generation,
175
+ partition,
176
+ batchSize,
177
+ signal,
178
+ });
179
+ if (typeof extra === 'number' && extra > 0) await wait(extra, signal);
180
+ }
181
+ await waitForLag(signal);
182
+ },
183
+ };
184
+ }
185
+
186
+ // ─── Adaptive batch sizing (AIMD) ─────────────────────────────────────────────
187
+
188
+ /** How often a throttle change is reported, at most, per controller */
189
+ const THROTTLE_REPORT_INTERVAL_MS = 10_000;
190
+
191
+ /** The first extra pause once the batch is as small as it gets */
192
+ const FIRST_BACKOFF_MS = 100;
193
+
194
+ /**
195
+ * A latency-driven batch size, additive-increase / multiplicative-decrease —
196
+ * TCP's answer to "how fast may I go without knowing the capacity": every
197
+ * batch written within `targetLatencyMs` grows the next one a little; one
198
+ * slower, or a sign of overload (a write concern timeout, a transient error,
199
+ * a lagging secondary), halves it — at most once per round trip, so one slow
200
+ * moment is not punished twice. At `minBatchSize` it backs off in time
201
+ * instead: an extra pause that doubles up to `maxPauseMs` and halves again as
202
+ * things recover. The first batch only warms up.
203
+ *
204
+ * Works on every topology — behind a mongos and on a standalone it is the
205
+ * only signal there is. `settings` is the resolved `spec.adaptive`;
206
+ * `initial` the state saved on the partition (`{ batchSize, pauseMs }`).
207
+ */
208
+ function createAdaptive(settings, { initial, now = Date.now } = {}) {
209
+ const { targetLatencyMs, minBatchSize, maxBatchSize, maxPauseMs } = settings;
210
+ const increase = Math.max(1, Math.floor(maxBatchSize / 20));
211
+ let size = clamp(initial?.batchSize ?? maxBatchSize, minBatchSize, maxBatchSize);
212
+ let pause = clamp(initial?.pauseMs ?? 0, 0, maxPauseMs);
213
+ let warm = initial !== undefined;
214
+ let lastDecrease = -Infinity;
215
+ let lastReport = -Infinity;
216
+ let pendingReason;
217
+
218
+ return {
219
+ batchSize: () => size,
220
+ pauseMs: () => pause,
221
+ state: () => ({ batchSize: size, pauseMs: pause }),
222
+ /**
223
+ * Judge one batch: `{ latencyMs, overloaded? }`. Returns the change to
224
+ * report — `{ reason, batchSize, pauseMs }` with `reason` `'slow'`,
225
+ * `'overload'` or `'recover'` — at most every 10 s, else `undefined`.
226
+ */
227
+ record({ latencyMs, overloaded = false }) {
228
+ if (!warm) {
229
+ warm = true;
230
+ return undefined;
231
+ }
232
+ const slow = latencyMs > targetLatencyMs;
233
+ let reason;
234
+ if (overloaded || slow) {
235
+ // Once per round trip: the batch that saw the last decrease does not count twice.
236
+ if (now() - lastDecrease < Math.max(latencyMs, 1)) return undefined;
237
+ lastDecrease = now();
238
+ reason = overloaded ? 'overload' : 'slow';
239
+ if (size > minBatchSize) size = Math.max(minBatchSize, Math.floor(size / 2));
240
+ else pause = Math.min(maxPauseMs, Math.max(FIRST_BACKOFF_MS, pause * 2));
241
+ } else if (pause > 0 || size < maxBatchSize) {
242
+ reason = 'recover';
243
+ if (pause > 0) pause = Math.floor(pause / 2);
244
+ else size = Math.min(maxBatchSize, size + increase);
245
+ }
246
+ if (reason === undefined) return undefined;
247
+ pendingReason =
248
+ pendingReason === 'recover' || pendingReason === undefined ? reason : pendingReason;
249
+ if (now() - lastReport < THROTTLE_REPORT_INTERVAL_MS) return undefined;
250
+ lastReport = now();
251
+ const report = { reason: pendingReason, batchSize: size, pauseMs: pause };
252
+ pendingReason = undefined;
253
+ return report;
254
+ },
255
+ };
256
+ }
257
+
258
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
259
+
260
+ module.exports = {
261
+ LAG_CHECK_INTERVAL_MS,
262
+ THROTTLE_REPORT_INTERVAL_MS,
263
+ createAdaptive,
264
+ createThrottle,
265
+ excludedMembers,
266
+ jitter,
267
+ replicationLagMs,
268
+ sleep,
269
+ };
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The live drift watcher's decisions, kept pure: which background migrations
3
+ * a collection's watcher upgrades with, what its change stream asks the
4
+ * server for, how a stream error is read, and when a resume token is due.
5
+ * background-watch.js does the I/O.
6
+ */
7
+
8
+ /** Server errors that mean the stream cannot resume from where it was */
9
+ const HISTORY_LOST = new Set([
10
+ 286, // ChangeStreamHistoryLost — the oplog moved past the token
11
+ 280, // ChangeStreamFatalError
12
+ 136, // CappedPositionLost
13
+ ]);
14
+ /** Not authorized to open (or keep) a change stream there */
15
+ const UNAUTHORIZED = 13;
16
+ /** "The $changeStream stage is only supported on replica sets" — a standalone */
17
+ const UNSUPPORTED = 40573;
18
+
19
+ /** Events that end a stream for good: the collection is gone or renamed */
20
+ const ENDING = new Set(['drop', 'rename', 'dropDatabase', 'invalidate']);
21
+
22
+ /**
23
+ * The background migrations a collection's watcher may upgrade with — its
24
+ * edges: completed, forward, declarative ones (a step migration cannot run
25
+ * on one document). Returns `{ edges, target }`: `edges` by the version they
26
+ * start from (the first registered wins a tie), `target` the highest
27
+ * version any reaches — the shape a document should have at least.
28
+ */
29
+ function edgesOf(states, collection) {
30
+ const edges = new Map();
31
+ let target;
32
+ for (const state of states) {
33
+ const spec = state.spec;
34
+ if (spec?.collection !== collection || spec.mode !== 'declarative') continue;
35
+ if (state.status !== 'completed' || state.direction === 'revert') continue;
36
+ if (!edges.has(spec.from)) {
37
+ edges.set(spec.from, { name: state._id, from: spec.from, to: spec.to });
38
+ }
39
+ if (target === undefined || spec.to > target) target = spec.to;
40
+ }
41
+ return { edges, target };
42
+ }
43
+
44
+ /** Statuses after which a background migration does nothing more */
45
+ const SETTLED = new Set(['completed', 'failed', 'cancelled']);
46
+
47
+ /**
48
+ * The revert that makes a collection's watcher stand aside, if any: one not
49
+ * settled yet — the watcher would upgrade straight back what it rewrites.
50
+ */
51
+ function suspendedBy(states, collection) {
52
+ for (const state of states) {
53
+ if (state.spec?.collection !== collection || state.direction !== 'revert') continue;
54
+ if (!SETTLED.has(state.status)) return state._id;
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ /**
60
+ * The change stream's pipeline: only the writes that can leave a document
61
+ * below `target` — an insert or replace of an old shape, an update that sets
62
+ * the version field below it or to null, one that removes it — plus the
63
+ * events that end the stream. No `updateLookup`: the watcher reads the one
64
+ * document an event names, never one per update of the collection. An update
65
+ * that does not touch the version field matches nothing (`$exists` guards
66
+ * the null test, which would otherwise match every such update).
67
+ */
68
+ function watchPipeline(field, target) {
69
+ const full = `fullDocument.${field}`;
70
+ const updated = `updateDescription.updatedFields.${field}`;
71
+ return [
72
+ {
73
+ $match: {
74
+ $or: [
75
+ {
76
+ operationType: { $in: ['insert', 'replace'] },
77
+ $or: [{ [full]: { $lt: target } }, { [full]: { $exists: false } }, { [full]: null }],
78
+ },
79
+ {
80
+ operationType: 'update',
81
+ $or: [
82
+ { [updated]: { $lt: target } },
83
+ { [updated]: { $exists: true, $type: 'null' } },
84
+ { 'updateDescription.removedFields': field },
85
+ ],
86
+ },
87
+ { operationType: { $in: [...ENDING] } },
88
+ ],
89
+ },
90
+ },
91
+ { $project: { operationType: 1, documentKey: 1, clusterTime: 1 } },
92
+ ];
93
+ }
94
+
95
+ /**
96
+ * How a stream error is met: `history-lost` (start over from now, after a
97
+ * drift probe of the collection), `unauthorized` (leave the collection to
98
+ * the polling watch), `unsupported` (no change streams here at all), or
99
+ * `retry` (reopen from the last token, after a backoff).
100
+ */
101
+ function classifyStreamError(error) {
102
+ const code = error?.code;
103
+ if (HISTORY_LOST.has(code)) return 'history-lost';
104
+ if (code === UNAUTHORIZED) return 'unauthorized';
105
+ if (code === UNSUPPORTED) return 'unsupported';
106
+ if (error?.hasErrorLabel?.('NonResumableChangeStreamError')) return 'history-lost';
107
+ return 'retry';
108
+ }
109
+
110
+ /** Whether an event ends the stream (the collection dropped or renamed) */
111
+ const isEnding = (event) => ENDING.has(event?.operationType);
112
+
113
+ /** Milliseconds since the event happened — the stream's lag — or `undefined` without a time */
114
+ function lagOf(event, now) {
115
+ const time = event?.clusterTime;
116
+ if (time === undefined || time === null) return undefined;
117
+ // A BSON Timestamp: seconds in `t` (the high 32 bits), an ordinal in `i`.
118
+ const seconds = time.t;
119
+ return typeof seconds === 'number' ? Math.max(0, now - seconds * 1000) : undefined;
120
+ }
121
+
122
+ /** Whether a resume token is due: at most every `checkpointMs`, and always when told to */
123
+ function tokenDue(lastSavedAt, now, checkpointMs, { force = false } = {}) {
124
+ return force || lastSavedAt === undefined || now - lastSavedAt >= checkpointMs;
125
+ }
126
+
127
+ /**
128
+ * A leader's view of its collection, in one pass over the states: the revert
129
+ * it stands aside for (`suspended`), its `edges` and `target` (as
130
+ * {@link edgesOf}), and every state by name.
131
+ */
132
+ function watchView(states, collection) {
133
+ const edges = new Map();
134
+ const byName = new Map();
135
+ let target;
136
+ let suspended;
137
+ for (const state of states) {
138
+ byName.set(state._id, state);
139
+ const spec = state.spec;
140
+ if (spec?.collection !== collection) continue;
141
+ if (state.direction === 'revert') {
142
+ if (suspended === undefined && !SETTLED.has(state.status)) suspended = state._id;
143
+ continue;
144
+ }
145
+ if (spec.mode !== 'declarative' || state.status !== 'completed') continue;
146
+ if (!edges.has(spec.from)) {
147
+ edges.set(spec.from, { name: state._id, from: spec.from, to: spec.to });
148
+ }
149
+ if (target === undefined || spec.to > target) target = spec.to;
150
+ }
151
+ return { suspended, edges, target, byName };
152
+ }
153
+
154
+ module.exports = {
155
+ HISTORY_LOST,
156
+ classifyStreamError,
157
+ edgesOf,
158
+ isEnding,
159
+ lagOf,
160
+ suspendedBy,
161
+ tokenDue,
162
+ watchPipeline,
163
+ watchView,
164
+ };
@@ -0,0 +1,78 @@
1
+ const os = require('node:os');
2
+ const { backgroundCollectionNames } = require('./config.js');
3
+ const { READ_OPTIONS } = require('./server-info.js');
4
+
5
+ /**
6
+ * Where the live drift watcher keeps what outlives a process — one document
7
+ * per watched collection in `<backgroundCollection>_watch`:
8
+ * `{ _id: collection, resumeToken?, target, edges, state, leader,
9
+ * counters, lastEventAt, lastError, updatedAt }`.
10
+ *
11
+ * Only the leader writes, and every write is fenced by `leader.owner`: a
12
+ * watcher that lost its lock (and so its leadership) without knowing it
13
+ * cannot move the token back. The token never leaves this module's status
14
+ * reads — it is the stream's position, of no use to anyone else.
15
+ */
16
+ class BackgroundWatchStore {
17
+ #collection;
18
+
19
+ constructor(db, backgroundCollection) {
20
+ this.#collection = db.collection(backgroundCollectionNames(backgroundCollection).watch);
21
+ }
22
+
23
+ /** The stored document of a collection's watcher, token included, or null */
24
+ async get(collection) {
25
+ return this.#collection.findOne({ _id: collection }, READ_OPTIONS);
26
+ }
27
+
28
+ /** Become the writer of a collection's document — called while holding its watcher lock */
29
+ async lead(collection, owner) {
30
+ await this.#collection.updateOne(
31
+ { _id: collection },
32
+ {
33
+ $set: {
34
+ leader: { owner, host: os.hostname(), pid: process.pid, at: new Date() },
35
+ updatedAt: new Date(),
36
+ },
37
+ $setOnInsert: { counters: {} },
38
+ },
39
+ { upsert: true },
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Save what the leader knows: `fields` set (a `resumeToken`, a `state`, …),
45
+ * `counters` added, `unset` removed. Resolves whether `owner` still leads.
46
+ */
47
+ async save(collection, owner, { fields = {}, counters = {}, unset = [] } = {}) {
48
+ const update = { $set: { ...fields, updatedAt: new Date() } };
49
+ const inc = {};
50
+ for (const [key, value] of Object.entries(counters)) {
51
+ if (value !== 0) inc[`counters.${key}`] = value;
52
+ }
53
+ if (Object.keys(inc).length > 0) update.$inc = inc;
54
+ if (unset.length > 0) {
55
+ update.$unset = {};
56
+ for (const key of unset) update.$unset[key] = '';
57
+ }
58
+ const result = await this.#collection.updateOne(
59
+ { _id: collection, 'leader.owner': owner },
60
+ update,
61
+ );
62
+ return result.matchedCount === 1;
63
+ }
64
+
65
+ /** Every watched collection's document — or one — without its resume token */
66
+ async status(collection) {
67
+ const projection = { resumeToken: 0 };
68
+ if (collection !== undefined) {
69
+ return this.#collection.findOne({ _id: collection }, { projection, ...READ_OPTIONS });
70
+ }
71
+ return this.#collection
72
+ .find({}, { projection, ...READ_OPTIONS })
73
+ .sort({ _id: 1 })
74
+ .toArray();
75
+ }
76
+ }
77
+
78
+ module.exports = { BackgroundWatchStore };