@procedures/mongo 0.43.0 → 0.44.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.
@@ -77,7 +77,10 @@ import {
77
77
  type Cancel,
78
78
  type Clock,
79
79
  type CollectionChangeEvent,
80
+ type LogFields,
81
+ type Logger,
80
82
  type ResubscribeBackoff,
83
+ writeLog,
81
84
  } from "@procedures/client";
82
85
  import {
83
86
  DEFAULT_STORED_FORM,
@@ -196,7 +199,17 @@ export interface WatchStorageChangesOptions {
196
199
  backoff?: ResubscribeBackoff | undefined;
197
200
  /** The clock of the timers and of the time limit of the own writes (default the clock of the platform). The timers do not keep a process alive. */
198
201
  clock?: Clock | undefined;
199
- /** The log (default `console`, with the prefix `[storage-hints]`). */
202
+ /**
203
+ * The logger of the watcher (CLI-88). The fields of each line hold its context: `collection`,
204
+ * `operationType`, `resumeToken` (true when the watcher has a token), `error`, `attempt` and
205
+ * `delayMs`. When both are set, `logger` wins over `log`.
206
+ * @default the console, with the prefix `[storage-hints]`
207
+ */
208
+ logger?: Logger | undefined;
209
+ /**
210
+ * The log function of each line, with the context in the text.
211
+ * @deprecated Use `logger`. Without a `logger`, the watcher still uses `log`.
212
+ */
200
213
  log?: ((level: "info" | "warn" | "error", message: string) => void) | undefined;
201
214
  }
202
215
 
@@ -227,7 +240,7 @@ const SEQ = "_seq";
227
240
  const KEY = "_key";
228
241
 
229
242
  /** The codes of the errors after which the server does not know the resume token: the watcher opens a new stream and publishes a gap. */
230
- const LOST_TOKEN_CODES: ReadonlySet<number> = new Set([260, 280, 286]);
243
+ export const LOST_TOKEN_CODES: ReadonlySet<number> = new Set([260, 280, 286]);
231
244
 
232
245
  /** The codes of a refused access: Unauthorized, AuthenticationFailed, and the access error of Atlas. */
233
246
  const REFUSED_CODES: ReadonlySet<number> = new Set([13, 18, 8000]);
@@ -262,7 +275,7 @@ function resetHint(): StorageChangeHint {
262
275
  }
263
276
 
264
277
  /** The code of a command that the server does not know (an old server without `hello`). */
265
- const COMMAND_NOT_FOUND = 59;
278
+ export const COMMAND_NOT_FOUND = 59;
266
279
 
267
280
  function asDocument(value: unknown): Document | undefined {
268
281
  return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Document) : undefined;
@@ -354,7 +367,7 @@ export function storageChangeOf(change: Document, form: StoredForm = DEFAULT_STO
354
367
  }
355
368
 
356
369
  /** The delay before try `attempt` (0 for the first) of a backoff. */
357
- function backoffOf(options: ResubscribeBackoff | undefined): (attempt: number) => number {
370
+ export function backoffOf(options: ResubscribeBackoff | undefined): (attempt: number) => number {
358
371
  const initial = optionalMs(options?.initialDelay, "backoff.initialDelay") ?? 250;
359
372
  const max = optionalMs(options?.maxDelay, "backoff.maxDelay") ?? 30_000;
360
373
  const factor = options?.backoffMultiplier ?? 2;
@@ -369,11 +382,11 @@ function backoffOf(options: ResubscribeBackoff | undefined): (attempt: number) =
369
382
  }
370
383
 
371
384
  /** The message of an error. */
372
- function messageOf(error: unknown): string {
385
+ export function messageOf(error: unknown): string {
373
386
  return String((error as Error | undefined)?.message ?? error);
374
387
  }
375
388
 
376
- function codeOf(error: unknown): number | undefined {
389
+ export function codeOf(error: unknown): number | undefined {
377
390
  const code = (error as { code?: unknown } | undefined)?.code;
378
391
  return typeof code === "number" ? code : undefined;
379
392
  }
@@ -385,7 +398,7 @@ function isRefused(error: unknown): boolean {
385
398
  }
386
399
 
387
400
  /** True for an error of a first open that does not go away: a single server, a refused access, a wrong option. */
388
- function isPermanent(error: unknown): boolean {
401
+ export function isPermanent(error: unknown): boolean {
389
402
  const code = codeOf(error);
390
403
  if (code !== undefined && PERMANENT_CODES.has(code)) return true;
391
404
  return error instanceof MongoAPIError || error instanceof MongoParseError;
@@ -441,7 +454,12 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
441
454
  const name = options.collection ?? source.collection;
442
455
  const topicOf = options.topic ?? ((collection: string, partition: string | undefined): string | undefined => collectionChangeTopic(collection, partition));
443
456
  const clock = options.clock ?? realClock;
444
- const log = options.log ?? ((level: "info" | "warn" | "error", message: string): void => console[level === "info" ? "log" : level](`[storage-hints] ${message}`));
457
+ const { logger, log: oldLog } = options;
458
+ /** One line: to `logger` with the fields, else to the old `log` with the text, else to the console with the text. */
459
+ const log = (level: "info" | "warn" | "error", message: string, fields: LogFields, text: string): void => {
460
+ if (!logger && oldLog) oldLog(level, text);
461
+ else writeLog(logger, level, message, { component: "storage-hints", ...fields }, `[storage-hints] ${text}`);
462
+ };
445
463
  const emit: (topic: string, event: StorageChangeHint) => void | Promise<void> = typeof publish === "function" ? publish : publish.emit.bind(publish);
446
464
  // The subscribed topics of the bus name the partitions of a gap. A custom topic rule cannot be read back.
447
465
  const channels = typeof publish === "object" && typeof publish.channels === "function" && options.topic === undefined ? publish.channels.bind(publish) : undefined;
@@ -510,9 +528,9 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
510
528
  topic = topicOf(name, partition);
511
529
  if (topic === undefined) return;
512
530
  const result: unknown = emit(topic, event);
513
- if (result instanceof Promise) result.catch((error: unknown) => log("error", `the publish on ${topic} failed: ${messageOf(error)}`));
531
+ if (result instanceof Promise) result.catch((error: unknown) => log("error", "The publish of a hint failed", { collection: name, topic, partition, error }, `the publish on ${topic} failed: ${messageOf(error)}`));
514
532
  } catch (error) {
515
- log("error", `the publish on ${topic ?? name} failed: ${messageOf(error)}`);
533
+ log("error", "The publish of a hint failed", { collection: name, topic, partition, error }, `the publish on ${topic ?? name} failed: ${messageOf(error)}`);
516
534
  }
517
535
  };
518
536
 
@@ -532,18 +550,18 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
532
550
  if (parsed?.collection === name) into.add(parsed.partition);
533
551
  }
534
552
  } catch (error) {
535
- log("warn", `the bus does not tell its channels: ${messageOf(error)}`);
553
+ log("warn", "The bus does not tell its channels", { collection: name, error }, `the bus does not tell its channels: ${messageOf(error)}`);
536
554
  }
537
555
  try {
538
556
  for (const partition of options.gapPartitions?.() ?? []) into.add(partition);
539
557
  } catch (error) {
540
- log("warn", `gapPartitions failed: ${messageOf(error)}`);
558
+ log("warn", "gapPartitions failed", { collection: name, error }, `gapPartitions failed: ${messageOf(error)}`);
541
559
  }
542
560
  };
543
561
 
544
562
  /** The reset hint of a gap: the subscribers read the collection in full. */
545
- const gap = (reason: string): void => {
546
- log("warn", `${reason}: the subscribers read the collection in full`);
563
+ const gap = (reason: string, fields: LogFields): void => {
564
+ log("warn", "A gap in the change stream: the subscribers read the collection in full", { collection: source.collection, reason, ...fields }, `${reason}: the subscribers read the collection in full`);
547
565
  // The raw writes of the gap have no event: the sweep adopts the documents without _seq
548
566
  if (sweepOn) {
549
567
  sweepWanted = true;
@@ -557,7 +575,7 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
557
575
  (known) => {
558
576
  for (const partition of known) topics.add(partition);
559
577
  },
560
- (error: unknown) => log("warn", `the counters of ${source.collection} cannot be read (${messageOf(error)}): the gap goes to the partitions of the hints and of the subscribers only`)
578
+ (error: unknown) => log("warn", "The counters cannot be read: the gap goes to the partitions of the hints and of the subscribers only", { collection: source.collection, error }, `the counters of ${source.collection} cannot be read (${messageOf(error)}): the gap goes to the partitions of the hints and of the subscribers only`)
561
579
  )
562
580
  .then(() => {
563
581
  if (stopped) return;
@@ -592,7 +610,7 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
592
610
  redo.set(key, { item: job.item, raw: job.raw, tries: 0 });
593
611
  return true;
594
612
  }
595
- if (!redoFull) log("warn", `more than ${redoLimit} raw writes of ${source.collection} wait to be adopted again: the partitions of the next ones get a reset hint, and a sweep runs`);
613
+ if (!redoFull) log("warn", "The set of the raw writes to adopt again is full: the next ones get a reset hint, and a sweep runs", { collection: source.collection, limit: redoLimit }, `more than ${redoLimit} raw writes of ${source.collection} wait to be adopted again: the partitions of the next ones get a reset hint, and a sweep runs`);
596
614
  redoFull = true;
597
615
  return false;
598
616
  };
@@ -614,7 +632,7 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
614
632
  if (result === "unknown") {
615
633
  if (job.kind === "sweep") leave(job.raw.storedId, "neither its id nor its partition");
616
634
  else {
617
- log("warn", `the raw write of ${describeId(job.raw.storedId)} in ${source.collection} tells neither its id nor its partition: the storage leaves it`);
635
+ log("warn", "A raw write tells neither its id nor its partition: the storage leaves it", { collection: source.collection, storedId: describeId(job.raw.storedId) }, `the raw write of ${describeId(job.raw.storedId)} in ${source.collection} tells neither its id nor its partition: the storage leaves it`);
618
636
  publishItem(job.item);
619
637
  }
620
638
  }
@@ -622,7 +640,7 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
622
640
  }
623
641
  const error = result.error;
624
642
  if (isRefused(error)) {
625
- if (!refused) log("error", `the adoption of a raw write of ${source.collection} was refused (${messageOf(error)}): the watcher needs write access, or set rawWrites: "ignore". The hints of raw writes go out without a version`);
643
+ if (!refused) log("error", "The adoption of a raw write was refused: the hints of raw writes go out without a version", { collection: source.collection, error }, `the adoption of a raw write of ${source.collection} was refused (${messageOf(error)}): the watcher needs write access, or set rawWrites: "ignore". The hints of raw writes go out without a version`);
626
644
  refused = true;
627
645
  publishItem(job.item);
628
646
  return job.done?.();
@@ -662,7 +680,7 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
662
680
  if (first) {
663
681
  // The last try failed: a reset hint for each partition. The redo tries once more; a second failure is for good.
664
682
  const names = [...new Set(failed.map(({ job }) => job.item.partition))].map((partition) => (partition === undefined ? "(none)" : JSON.stringify(partition)));
665
- log("warn", `the adoption of ${failed.length} raw writes of ${source.collection} failed (${messageOf(first.error)}): the partitions ${names.join(", ")} get a reset hint`);
683
+ log("warn", "The adoption of raw writes failed: their partitions get a reset hint", { collection: source.collection, count: failed.length, partitions: names, error: first.error }, `the adoption of ${failed.length} raw writes of ${source.collection} failed (${messageOf(first.error)}): the partitions ${names.join(", ")} get a reset hint`);
666
684
  for (const { job } of failed) if (job.kind !== "redo" && !remember(job)) sweepWanted = true;
667
685
  resetPartitions(failed.map(({ job }) => job));
668
686
  }
@@ -679,7 +697,7 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
679
697
  const enqueue = (job: AdoptJob): void => {
680
698
  if (stopped) return job.done?.();
681
699
  if (queue.length >= queueLimit) {
682
- if (!full) log("warn", `more than ${queueLimit} raw writes of ${source.collection} wait for an adoption: the next ones are adopted again when the queue is empty`);
700
+ if (!full) log("warn", "The queue of the adoptions is full: the next raw writes are adopted again when it is empty", { collection: source.collection, limit: queueLimit }, `more than ${queueLimit} raw writes of ${source.collection} wait for an adoption: the next ones are adopted again when the queue is empty`);
683
701
  full = true;
684
702
  if (remember(job)) {
685
703
  // The hint of a raw write of a change event goes out now, without a version. Its adoption gives the version.
@@ -744,10 +762,10 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
744
762
  if (page.length < sweepPage) break;
745
763
  }
746
764
  if (stopped) return;
747
- if (count > 0) log("info", `the sweep of ${source.collection} sent ${count} documents without _seq to the adoption`);
748
- if (leftCount > 0) log("warn", `the sweep of ${source.collection} cannot place ${leftCount} documents without _seq, for example ${leftFirst}: the next sweeps leave them`);
765
+ if (count > 0) log("info", "The sweep sent the documents without _seq to the adoption", { collection: source.collection, count }, `the sweep of ${source.collection} sent ${count} documents without _seq to the adoption`);
766
+ if (leftCount > 0) log("warn", "The sweep cannot place documents without _seq: the next sweeps leave them", { collection: source.collection, count: leftCount, first: leftFirst }, `the sweep of ${source.collection} cannot place ${leftCount} documents without _seq, for example ${leftFirst}: the next sweeps leave them`);
749
767
  } catch (error) {
750
- if (!stopped) log("warn", `the sweep of the documents without _seq of ${source.collection} failed: ${messageOf(error)}`);
768
+ if (!stopped) log("warn", "The sweep of the documents without _seq failed", { collection: source.collection, error }, `the sweep of the documents without _seq of ${source.collection} failed: ${messageOf(error)}`);
751
769
  }
752
770
  };
753
771
 
@@ -764,7 +782,7 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
764
782
  docs = await source.storedDocuments(part.map((job) => job.raw.storedId));
765
783
  } catch (error) {
766
784
  if (stopped) return;
767
- log("warn", `the documents of ${part.length} raw writes of ${source.collection} to adopt again cannot be read (${messageOf(error)}): their partitions get a reset hint`);
785
+ log("warn", "The documents of raw writes to adopt again cannot be read: their partitions get a reset hint", { collection: source.collection, count: part.length, error }, `the documents of ${part.length} raw writes of ${source.collection} to adopt again cannot be read (${messageOf(error)}): their partitions get a reset hint`);
768
786
  resetPartitions(part);
769
787
  continue;
770
788
  }
@@ -799,7 +817,7 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
799
817
  if (redo.size === 0) return;
800
818
  redoing = true;
801
819
  void adoptAgain()
802
- .catch((error: unknown) => log("warn", `the redo of the raw writes of ${source.collection} failed: ${messageOf(error)}`))
820
+ .catch((error: unknown) => log("warn", "The redo of the raw writes failed", { collection: source.collection, error }, `the redo of the raw writes of ${source.collection} failed: ${messageOf(error)}`))
803
821
  .finally(() => {
804
822
  redoing = false;
805
823
  afterQueue();
@@ -810,14 +828,14 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
810
828
  token = change["_id"];
811
829
  attempt = 0;
812
830
  const type = change["operationType"];
813
- if (type === "drop" || type === "rename" || type === "dropDatabase") return gap(`the collection ${source.collection} had a ${String(type)}`);
831
+ if (type === "drop" || type === "rename" || type === "dropDatabase") return gap(`the collection ${source.collection} had a ${String(type)}`, { operationType: type, resumeToken: true });
814
832
  const ns = asDocument(change["ns"]);
815
833
  if (ns?.["coll"] === source.counters) return;
816
834
  let item: StorageChange | undefined;
817
835
  try {
818
836
  item = storageChangeOf(change, source.form);
819
837
  } catch (error) {
820
- log("warn", `a change of ${source.collection} has no item: ${messageOf(error)}`);
838
+ log("warn", "A change has no item", { collection: source.collection, operationType: type, error }, `a change of ${source.collection} has no item: ${messageOf(error)}`);
821
839
  return;
822
840
  }
823
841
  if (!item) return;
@@ -834,10 +852,11 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
834
852
  adopt(item, change, before);
835
853
  };
836
854
 
837
- const schedule = (reason: string): void => {
855
+ const schedule = (reason: string, error?: unknown): void => {
838
856
  if (stopped) return;
839
- const delay = delayOf(attempt++);
840
- log("warn", `${reason}: the stream of ${source.collection} opens again in ${Math.round(delay)} ms`);
857
+ const delay = delayOf(attempt);
858
+ log("warn", "The change stream failed: it opens again after a backoff", { collection: source.collection, reason, ...(error !== undefined && { error }), attempt: attempt + 1, delayMs: Math.round(delay), resumeToken: token !== undefined }, `${reason}: the stream of ${source.collection} opens again in ${Math.round(delay)} ms`);
859
+ attempt++;
841
860
  timer = clock.after(delay, () => {
842
861
  timer = undefined;
843
862
  void open();
@@ -858,7 +877,7 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
858
877
 
859
878
  /** The end of a watcher whose first open failed with an error that does not go away. */
860
879
  const fail = (error: unknown): void => {
861
- log("error", `the stream of ${source.collection} cannot open (${messageOf(error)}): the watcher stops`);
880
+ log("error", "The change stream cannot open: the watcher stops", { collection: source.collection, error }, `the stream of ${source.collection} cannot open (${messageOf(error)}): the watcher stops`);
862
881
  stopped = true;
863
882
  endAdoptions();
864
883
  own?.close();
@@ -873,7 +892,7 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
873
892
  db = await source.db();
874
893
  } catch (error) {
875
894
  if (!opened && isPermanent(error)) return fail(error);
876
- return schedule(`the database is not available (${messageOf(error)})`);
895
+ return schedule(`the database is not available (${messageOf(error)})`, error);
877
896
  }
878
897
  if (stopped) return;
879
898
  if (!checked) {
@@ -885,7 +904,7 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
885
904
  // An old server without hello: the stream tells the error
886
905
  if (codeOf(error) !== COMMAND_NOT_FOUND) {
887
906
  if (!opened && isPermanent(error)) return fail(error);
888
- return schedule(`the server does not answer hello (${messageOf(error)})`);
907
+ return schedule(`the server does not answer hello (${messageOf(error)})`, error);
889
908
  }
890
909
  }
891
910
  if (stopped) return;
@@ -895,7 +914,7 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
895
914
  checked = true;
896
915
  }
897
916
  own ??= trackOwnNumbers(db, source.collection, { numbers: skipOwn, clock });
898
- if (token === undefined && opened) gap(`the stream of ${source.collection} opens again without a resume token`);
917
+ if (token === undefined && opened) gap(`the stream of ${source.collection} opens again without a resume token`, { resumeToken: false });
899
918
  let next: ChangeStream;
900
919
  try {
901
920
  next = db.collection(source.collection).watch([], {
@@ -905,7 +924,7 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
905
924
  });
906
925
  } catch (error) {
907
926
  if (!opened && isPermanent(error)) return fail(error);
908
- return schedule(`the stream cannot open (${messageOf(error)})`);
927
+ return schedule(`the stream cannot open (${messageOf(error)})`, error);
909
928
  }
910
929
  stream = next;
911
930
  let ended = false;
@@ -920,10 +939,10 @@ export function watchStorageChanges<T>(storage: MongoDocumentStorage<T>, publish
920
939
  const code = codeOf(error);
921
940
  if (code !== undefined && LOST_TOKEN_CODES.has(code) && token !== undefined) {
922
941
  // The next stream starts now, without the token: it publishes a gap
923
- log("warn", `the server does not know the resume token of ${source.collection} (code ${code})`);
942
+ log("warn", "The server does not know the resume token", { collection: source.collection, code, resumeToken: true }, `the server does not know the resume token of ${source.collection} (code ${code})`);
924
943
  token = undefined;
925
944
  }
926
- schedule(`the stream ${reason}`);
945
+ schedule(`the stream ${reason}`, error);
927
946
  };
928
947
  next.on("resumeTokenChanged", (resumeToken: unknown) => {
929
948
  if (resumeToken === undefined || resumeToken === null) return;