@alexify/migronaut 2.1.0 → 2.2.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.
@@ -53,6 +53,9 @@ const INDEX_KEYS = new Set([
53
53
  ...IGNORED_OPTIONS,
54
54
  ]);
55
55
 
56
+ /** Every option an index is normalized with — built once, read per index */
57
+ const NORMALIZED_OPTIONS = Object.freeze([...SEMANTIC_OPTIONS, ...DECLARED_ONLY_OPTIONS]);
58
+
56
59
  const BOOLEAN_OPTIONS = new Set(['unique', 'sparse', 'hidden', 'background']);
57
60
  const OBJECT_OPTIONS = new Set([
58
61
  'partialFilterExpression',
@@ -98,16 +101,20 @@ function keyIssues(key, report) {
98
101
  return null;
99
102
  }
100
103
  let usable = true;
104
+ let integerLike = false;
101
105
  for (const [field, direction] of entries) {
102
106
  if (typeof field !== 'string' || field.length === 0) {
103
107
  report('field names must be non-empty strings');
104
108
  usable = false;
105
- } else if (!DIRECTIONS.has(direction)) {
109
+ continue;
110
+ }
111
+ if (!integerLike && INTEGER_LIKE.test(field)) integerLike = true;
112
+ if (!DIRECTIONS.has(direction)) {
106
113
  report(`direction of "${field}" must be 1, -1, 'text', 'hashed', '2d' or '2dsphere'`);
107
114
  usable = false;
108
115
  }
109
116
  }
110
- if (usable && entries.length > 1 && entries.some(([field]) => INTEGER_LIKE.test(field))) {
117
+ if (usable && entries.length > 1 && integerLike) {
111
118
  if (!(key instanceof Map)) {
112
119
  report(
113
120
  'has an integer-like field name — JavaScript reorders such keys, so declare this key ' +
@@ -145,10 +152,14 @@ function indexIssues(index, path) {
145
152
  if (!INDEX_KEYS.has(key)) report(key)('is not a supported index option');
146
153
  }
147
154
  const entries = keyIssues(index.key, report('key'));
148
- const isText = entries?.some(([, direction]) => direction === TEXT) ?? false;
155
+ let isText = false;
149
156
  // `wildcardProjection` belongs to an all-fields wildcard (`$**`, compound or
150
157
  // not) — the server refuses it on a path wildcard such as `a.$**`.
151
- const isAllFieldsWildcard = entries?.some(([field]) => field === '$**') ?? false;
158
+ let isAllFieldsWildcard = false;
159
+ for (const [field, direction] of entries ?? []) {
160
+ if (direction === TEXT) isText = true;
161
+ if (field === '$**') isAllFieldsWildcard = true;
162
+ }
152
163
 
153
164
  if (index.name !== undefined) {
154
165
  if (typeof index.name !== 'string' || index.name.length === 0) {
@@ -209,23 +220,23 @@ function indexIssues(index, path) {
209
220
  }
210
221
 
211
222
  /**
212
- * The key as the server stores it. A text index is not stored under its
213
- * fields: they collapse into `_fts: 'text', _ftsx: 1` at the position of the
214
- * first text field, and move into `weights`.
223
+ * The key as the server stores it, and whether it is a text index — in one
224
+ * pass. A text index is not stored under its fields: they collapse into
225
+ * `_fts: 'text', _ftsx: 1` at the position of the first text field, and move
226
+ * into `weights`.
215
227
  */
216
228
  function serverKeyOf(entries) {
217
- if (!entries.some(([, direction]) => direction === TEXT)) return entries;
218
229
  const out = [];
219
- let placed = false;
230
+ let isText = false;
220
231
  for (const entry of entries) {
221
232
  if (entry[1] !== TEXT) {
222
233
  out.push(entry);
223
- } else if (!placed) {
234
+ } else if (!isText) {
224
235
  out.push(['_fts', TEXT], ['_ftsx', 1]);
225
- placed = true;
236
+ isText = true;
226
237
  }
227
238
  }
228
- return out;
239
+ return { serverKey: isText ? out : entries, isText };
229
240
  }
230
241
 
231
242
  /** Text fields at weight 1, then whatever the declaration weighs differently */
@@ -243,10 +254,10 @@ function textWeights(entries, declared) {
243
254
  */
244
255
  function normalizeDeclaredIndex(index) {
245
256
  const entries = keyEntries(index.key);
246
- const isText = entries.some(([, direction]) => direction === TEXT);
257
+ const { serverKey, isText } = serverKeyOf(entries);
247
258
  const name = index.name ?? defaultIndexName(entries);
248
259
  const options = {};
249
- for (const option of [...SEMANTIC_OPTIONS, ...DECLARED_ONLY_OPTIONS]) {
260
+ for (const option of NORMALIZED_OPTIONS) {
250
261
  const value = index[option];
251
262
  if (value === undefined) continue;
252
263
  // `false` is the server default; sending it would only make the stored
@@ -258,7 +269,7 @@ function normalizeDeclaredIndex(index) {
258
269
  return {
259
270
  name,
260
271
  entries,
261
- serverKey: serverKeyOf(entries),
272
+ serverKey,
262
273
  isText,
263
274
  options,
264
275
  // The key travels as a Map: the field order is the index, and the driver
@@ -271,7 +282,7 @@ function normalizeDeclaredIndex(index) {
271
282
  function normalizeLiveIndex(raw) {
272
283
  const entries = Object.entries(raw.key ?? {});
273
284
  const options = {};
274
- for (const option of [...SEMANTIC_OPTIONS, ...DECLARED_ONLY_OPTIONS]) {
285
+ for (const option of NORMALIZED_OPTIONS) {
275
286
  const value = raw[option];
276
287
  if (value === undefined) continue;
277
288
  if (BOOLEAN_OPTIONS.has(option)) {
package/src/core/lock.js CHANGED
@@ -229,10 +229,18 @@ class MigrationLock {
229
229
  }
230
230
 
231
231
  /**
232
- * Run `fn(signal)` while holding the migration lock. The lock is always
233
- * released in a `finally` block. While `fn` runs, a heartbeat renews the lock
234
- * every `ttlMs/2` so a migration that takes longer than the TTL never lets its
235
- * lock go stale and get reclaimed by another process.
232
+ * Run `fn(signal, control)` while holding the migration lock. The lock is
233
+ * always released in a `finally` block. While `fn` runs, a heartbeat renews the
234
+ * lock every `ttlMs/2` so a migration that takes longer than the TTL never lets
235
+ * its lock go stale and get reclaimed by another process.
236
+ *
237
+ * `control.release()` gives the lock up before `fn` returns — once, for a
238
+ * tail of work that only reads (converge waiting for search index builds):
239
+ * the heartbeat stops, the lock document goes, `onLockReleased({ early: true })`
240
+ * fires, and the signal no longer aborts on a lost lock. It resolves to
241
+ * whether the lock was released: one that fails is only warned about (the
242
+ * lock frees itself once its TTL runs out) and tried again at the end. With
243
+ * `noLock` there is nothing to release (`false`).
236
244
  *
237
245
  * If the lock is nonetheless lost — another process reclaimed it, or the
238
246
  * heartbeat cannot reach the database — the `signal` is aborted with a
@@ -254,7 +262,7 @@ async function runWithLock(lock, options, fn) {
254
262
  // `skipped: true` tells them apart from a real lock.
255
263
  options.onLockAcquired?.({ skipped: true });
256
264
  try {
257
- return await fn(controller.signal);
265
+ return await fn(controller.signal, { release: async () => false });
258
266
  } finally {
259
267
  options.onLockReleased?.({ skipped: true });
260
268
  }
@@ -352,23 +360,53 @@ async function runWithLock(lock, options, fn) {
352
360
  }, intervalMs);
353
361
  heartbeat.unref?.();
354
362
 
363
+ const stopHeartbeat = async () => {
364
+ stopped = true;
365
+ clearInterval(heartbeat);
366
+ clearTimeout(deadline);
367
+ // Let any renewal already in flight settle, so no stray query outlives this
368
+ // call and lands after the caller has closed the client.
369
+ await inFlight;
370
+ };
371
+ let released = false;
372
+ let releasing;
373
+ const control = {
374
+ release: () =>
375
+ (releasing ??= (async () => {
376
+ await stopHeartbeat();
377
+ try {
378
+ await lock.release();
379
+ released = true;
380
+ options.onLockReleased?.({ early: true });
381
+ } catch (releaseError) {
382
+ const message = errorText(releaseError);
383
+ options.logger.warn(
384
+ `⚠ Failed to release the migration lock early: ${message} — it frees itself once ` +
385
+ 'its TTL runs out',
386
+ { event: 'lock:release-failed', error: message, early: true },
387
+ );
388
+ }
389
+ return released;
390
+ })()),
391
+ };
392
+
355
393
  let result;
356
394
  let runError;
357
395
  let failed = false;
358
396
  try {
359
- result = await fn(controller.signal);
397
+ result = await fn(controller.signal, control);
360
398
  } catch (error) {
361
399
  failed = true;
362
400
  runError = error;
363
401
  } finally {
364
- stopped = true;
365
- clearInterval(heartbeat);
366
- clearTimeout(deadline);
367
- // Let any renewal already in flight settle, so no stray query outlives this
368
- // call and lands after the caller has closed the client.
369
- await inFlight;
402
+ await releasing;
403
+ await stopHeartbeat();
370
404
  }
371
405
 
406
+ if (released) {
407
+ if (failed) throw runError;
408
+ return result;
409
+ }
372
410
  try {
373
411
  await lock.release();
374
412
  options.onLockReleased?.();
@@ -408,10 +408,12 @@ class MigratorKit extends EventEmitter {
408
408
  }
409
409
 
410
410
  /**
411
- * Run `fn` under the migration lock. The single place that pairs a lock with
412
- * a unit of work, so `redo` can hold one lock across both directions instead
413
- * of releasing between them. `info` names the run (`{command, direction?}`)
414
- * for the `run:start`/`run:end` events.
411
+ * Run `fn(signal, lock)` under the migration lock. The single place that
412
+ * pairs a lock with a unit of work, so `redo` can hold one lock across both
413
+ * directions instead of releasing between them. `info` names the run
414
+ * (`{command, direction?}`) for the `run:start`/`run:end` events.
415
+ * `lock.release()` gives the lock up before `fn` returns, for a tail that
416
+ * only reads (see runWithLock) — the run, its id and its span go on.
415
417
  */
416
418
  async #withLock(options, info, fn) {
417
419
  // Not reentrant: a second overlapping run on this instance would clobber
@@ -468,7 +470,7 @@ class MigratorKit extends EventEmitter {
468
470
  onLockLostEvent: (reason) => recorder.lockLost(reason),
469
471
  ...(options.noLock ? { noLock: true } : {}),
470
472
  },
471
- (lockSignal) =>
473
+ (lockSignal, lockControl) =>
472
474
  // The run's span exists only once the lock is held: a caller polling
473
475
  // for a busy lock retries the whole run every few hundred
474
476
  // milliseconds, and a span per refusal would bury the one run that
@@ -476,7 +478,7 @@ class MigratorKit extends EventEmitter {
476
478
  // recorder after the release, so the span's outcome is the run's.
477
479
  this.#telemetry.open(SPANS.RUN, recorder.spanAttributes(), (span) => {
478
480
  recorder.spanOpened(span);
479
- return fn(AbortSignal.any([lockSignal, stopper.signal]));
481
+ return fn(AbortSignal.any([lockSignal, stopper.signal]), lockControl);
480
482
  }),
481
483
  );
482
484
  return result;
@@ -960,7 +962,7 @@ class MigratorKit extends EventEmitter {
960
962
  : [];
961
963
  return this.#keepDefinitionsWhileRefused(async () => {
962
964
  await this.connect();
963
- return this.#withLock(options, { command: 'up', direction: 'up' }, async (signal) => {
965
+ return this.#withLock(options, { command: 'up', direction: 'up' }, async (signal, lock) => {
964
966
  const results = await this.#runUp(filename, options, signal);
965
967
  // Even when nothing was pending: a converge that failed last time is
966
968
  // retried by the next `up` instead of waiting for the next migration.
@@ -968,8 +970,13 @@ class MigratorKit extends EventEmitter {
968
970
  this.#assertNotAborted(signal, results);
969
971
  try {
970
972
  await runConverge(
971
- this.#convergeDeps(),
972
- { definitions, trigger: 'up', ...pickActor(options) },
973
+ this.#convergeDeps(lock),
974
+ {
975
+ definitions,
976
+ trigger: 'up',
977
+ search: this.#convergeSearchOptions(),
978
+ ...pickActor(options),
979
+ },
973
980
  signal,
974
981
  );
975
982
  } catch (error) {
@@ -1550,6 +1557,7 @@ class MigratorKit extends EventEmitter {
1550
1557
  getDb: () => this.#requireDb(),
1551
1558
  inspectLock: () => this.#buildLock().inspect(),
1552
1559
  status: () => this.status(),
1560
+ definitions: () => this.#resolveCollections(),
1553
1561
  });
1554
1562
  }
1555
1563
 
@@ -1855,7 +1863,13 @@ class MigratorKit extends EventEmitter {
1855
1863
  await this.connect();
1856
1864
  return runConverge(
1857
1865
  this.#convergeDeps(),
1858
- { definitions, prune: options.prune, rebuildUnique: options.rebuildUnique, dryRun: true },
1866
+ {
1867
+ definitions,
1868
+ prune: options.prune,
1869
+ rebuildUnique: options.rebuildUnique,
1870
+ dryRun: true,
1871
+ search: this.#convergeSearchOptions(),
1872
+ },
1859
1873
  undefined,
1860
1874
  );
1861
1875
  }
@@ -1872,11 +1886,17 @@ class MigratorKit extends EventEmitter {
1872
1886
  return empty(false);
1873
1887
  }
1874
1888
  await this.connect();
1875
- return this.#withLock(options, { command: 'converge' }, async (signal) => {
1889
+ return this.#withLock(options, { command: 'converge' }, async (signal, lock) => {
1876
1890
  if (options.ordered) await this.#assertNothingPending();
1877
1891
  return runConverge(
1878
- this.#convergeDeps(),
1879
- { definitions, prune: options.prune, rebuildUnique: options.rebuildUnique, ...actor },
1892
+ this.#convergeDeps(lock),
1893
+ {
1894
+ definitions,
1895
+ prune: options.prune,
1896
+ rebuildUnique: options.rebuildUnique,
1897
+ search: this.#convergeSearchOptions(options),
1898
+ ...actor,
1899
+ },
1880
1900
  signal,
1881
1901
  );
1882
1902
  });
@@ -1912,10 +1932,23 @@ class MigratorKit extends EventEmitter {
1912
1932
  });
1913
1933
  }
1914
1934
 
1915
- #convergeDeps() {
1935
+ /** How converge treats search indexes: the config, and a call's own `waitForSearchIndexes` */
1936
+ #convergeSearchOptions(options = {}) {
1937
+ const config = this.#config;
1938
+ return {
1939
+ onUnavailable: config.onSearchUnavailable,
1940
+ wait: options.waitForSearchIndexes ?? config.waitForSearchIndexes,
1941
+ waitTimeoutMs: config.searchIndexWaitTimeoutMs,
1942
+ };
1943
+ }
1944
+
1945
+ /** What runConverge works with; `lock` (a run's) lets it give the lock up before waiting */
1946
+ #convergeDeps(lock) {
1916
1947
  const db = this.#requireDb();
1917
1948
  return {
1918
1949
  db,
1950
+ ...(lock ? { releaseLock: () => lock.release() } : {}),
1951
+ recordSearchWait: (waitedMs, outcome) => this.#telemetry.searchWaited({ waitedMs, outcome }),
1919
1952
  logger: this.#logger,
1920
1953
  fields: (extra) => this.#fields(extra),
1921
1954
  emit: (event, payload) => this.#emit(event, payload),
@@ -198,11 +198,26 @@ function assertDryRunOptions(filename, options) {
198
198
 
199
199
  /** `converge(options)` */
200
200
  function assertConvergeOptions(options) {
201
- for (const key of ['dryRun', 'prune', 'noLock', 'ordered', 'rebuildUnique']) {
201
+ for (const key of [
202
+ 'dryRun',
203
+ 'prune',
204
+ 'noLock',
205
+ 'ordered',
206
+ 'rebuildUnique',
207
+ 'waitForSearchIndexes',
208
+ ]) {
202
209
  if (options[key] !== undefined && typeof options[key] !== 'boolean') {
203
210
  throw new ConfigInvalidError(`${key} must be a boolean`, { [key]: options[key] });
204
211
  }
205
212
  }
213
+ // A dry run builds nothing, so there is nothing to wait for — asking for
214
+ // both is a mistake worth saying, not a wait that silently never happens.
215
+ if (options.dryRun && options.waitForSearchIndexes) {
216
+ throw new ConfigInvalidError('waitForSearchIndexes cannot be combined with dryRun', {
217
+ dryRun: true,
218
+ waitForSearchIndexes: true,
219
+ });
220
+ }
206
221
  assertActorValid(options);
207
222
  }
208
223