@alexify/migronaut 2.3.0 → 2.4.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.
@@ -1,3 +1,5 @@
1
+ const { isPlainObject } = require('./canonical.js');
2
+
1
3
  /**
2
4
  * Credential redaction for anything that leaves the process — error messages,
3
5
  * stacks, `--json` payloads, log lines. The MongoDB driver echoes the raw
@@ -69,6 +71,24 @@ function redactOutbound(text) {
69
71
  return redactUris(text).replace(DUPLICATE_KEY_VALUES, '$1{ <redacted> }');
70
72
  }
71
73
 
74
+ /**
75
+ * Set `key` on a copy as an own property. A plain assignment of `__proto__` —
76
+ * a key `JSON.parse` happily makes — would replace the copy's prototype and
77
+ * lose the key instead.
78
+ */
79
+ function put(target, key, value) {
80
+ if (key === '__proto__') {
81
+ Object.defineProperty(target, key, {
82
+ value,
83
+ enumerable: true,
84
+ writable: true,
85
+ configurable: true,
86
+ });
87
+ } else {
88
+ target[key] = value;
89
+ }
90
+ }
91
+
72
92
  /**
73
93
  * Redact every string reachable from `value` (plain objects and arrays only —
74
94
  * class instances are left alone rather than cloned into broken shapes).
@@ -81,12 +101,129 @@ function redactDeep(value) {
81
101
  for (let index = 0; index < value.length; index++) copy[index] = redactDeep(value[index]);
82
102
  return copy;
83
103
  }
84
- if (value !== null && typeof value === 'object' && value.constructor === Object) {
104
+ if (isPlainObject(value)) {
85
105
  const copy = {};
86
- for (const key of Object.keys(value)) copy[key] = redactDeep(value[key]);
106
+ for (const key of Object.keys(value)) put(copy, key, redactDeep(value[key]));
87
107
  return copy;
88
108
  }
89
109
  return value;
90
110
  }
91
111
 
92
- module.exports = { redactDeep, redactOutbound, redactUris };
112
+ /**
113
+ * How much of a value {@link redactBounded} copies: nesting, entries in all,
114
+ * the length of one string and of one piece of binary data. What a migration
115
+ * hands to `ctx.logger` is the application's own data, of any size and shape —
116
+ * a cycle included — and a subscriber stores it; these keep one call's copy
117
+ * small and finite.
118
+ */
119
+ const BOUNDS = Object.freeze({ depth: 8, entries: 1000, string: 4096, bytes: 4096 });
120
+
121
+ /** Stands in for what {@link redactBounded} left out */
122
+ const TRUNCATED = '[truncated]';
123
+
124
+ /** The size of binary data — a Buffer or another Uint8Array, a BSON Binary — or undefined */
125
+ function byteLength(item) {
126
+ if (item instanceof Uint8Array) return item.byteLength;
127
+ if (item._bsontype === 'Binary') return item.position;
128
+ return undefined;
129
+ }
130
+
131
+ /**
132
+ * An Error as data: its name, its message — credentials and the values a
133
+ * server error quotes masked, as for anything that leaves the process — and
134
+ * its code. Not the stack, and not what else it carries: a driver error's raw
135
+ * server response repeats the offending document's values (`keyValue`,
136
+ * `errmsg`), and its `message` is not even enumerable, so the error itself
137
+ * would be stored as an empty document.
138
+ */
139
+ function errorData(error) {
140
+ const data = {
141
+ name: String(error.name),
142
+ message: redactOutbound(typeof error.message === 'string' ? error.message : ''),
143
+ };
144
+ if (typeof error.code === 'number' || typeof error.code === 'string') data.code = error.code;
145
+ if (typeof error.codeName === 'string') data.codeName = error.codeName;
146
+ return data;
147
+ }
148
+
149
+ /**
150
+ * {@link redactDeep} within {@link BOUNDS}, for data that leaves the process
151
+ * as a document: every string reachable redacted ({@link redactOutbound}: the
152
+ * values a server error quotes too) and clipped, nesting past
153
+ * the depth (which is what ends a cycle) and entries past the budget replaced
154
+ * or dropped. What the driver stores as a value of its own is kept as is — a
155
+ * Date, a RegExp, an ObjectId or another BSON value, and binary data within
156
+ * its bound. An Error becomes `{ name, message, code?, codeName? }`; a Map is
157
+ * copied as an object and a Set as an array; any other instance goes by its
158
+ * `toJSON()`, or its own fields, the way `JSON.stringify` would see it.
159
+ * `omit` names a key left out of the top level only. Returns
160
+ * `{ value, truncated }`; never mutates the input.
161
+ */
162
+ function redactBounded(value, { omit } = {}) {
163
+ let entries = 0;
164
+ let truncated = false;
165
+ /** One more entry, or false once the budget is spent */
166
+ const take = () => {
167
+ if (entries >= BOUNDS.entries) {
168
+ truncated = true;
169
+ return false;
170
+ }
171
+ entries += 1;
172
+ return true;
173
+ };
174
+ const copy = (item, depth) => {
175
+ if (typeof item === 'string') {
176
+ // Outbound, like the event's message: an error's text copied into a
177
+ // field (`{ error: err.message }`) loses the values it quotes too.
178
+ const text = redactOutbound(item);
179
+ if (text.length <= BOUNDS.string) return text;
180
+ truncated = true;
181
+ return `${text.slice(0, BOUNDS.string)}…`;
182
+ }
183
+ if (item === null || typeof item !== 'object') return item;
184
+ if (item instanceof Date || item instanceof RegExp) return item;
185
+ const bytes = byteLength(item);
186
+ if (bytes !== undefined) {
187
+ if (bytes <= BOUNDS.bytes) return item;
188
+ truncated = true;
189
+ return TRUNCATED;
190
+ }
191
+ if (typeof item._bsontype === 'string') return item;
192
+ if (item instanceof Error) return copy(errorData(item), depth);
193
+ if (depth >= BOUNDS.depth) {
194
+ truncated = true;
195
+ return TRUNCATED;
196
+ }
197
+ if (Array.isArray(item) || item instanceof Set) {
198
+ const out = [];
199
+ for (const element of item) {
200
+ if (!take()) break;
201
+ out.push(copy(element, depth + 1));
202
+ }
203
+ return out;
204
+ }
205
+ const out = {};
206
+ if (item instanceof Map) {
207
+ for (const [key, element] of item) {
208
+ if (!take()) break;
209
+ put(out, String(key), copy(element, depth + 1));
210
+ }
211
+ return out;
212
+ }
213
+ // What JSON.stringify would see: a toJSON() result one level down (so a
214
+ // chain of them ends at the depth bound), else the instance's own fields.
215
+ if (!isPlainObject(item) && typeof item.toJSON === 'function') {
216
+ return copy(item.toJSON(), depth + 1);
217
+ }
218
+ for (const key of Object.keys(item)) {
219
+ if (depth === 0 && key === omit) continue;
220
+ if (!take()) break;
221
+ put(out, key, copy(item[key], depth + 1));
222
+ }
223
+ return out;
224
+ };
225
+ const result = copy(value, 0);
226
+ return { value: result, truncated };
227
+ }
228
+
229
+ module.exports = { BOUNDS, redactBounded, redactDeep, redactOutbound, redactUris };
@@ -37,6 +37,9 @@ const ATTRIBUTES = {
37
37
  MIGRATION_INDEX: 'migronaut.migration.index',
38
38
  MIGRATION_TOTAL: 'migronaut.migration.total',
39
39
  MIGRATION_TRANSACTION: 'migronaut.migration.transaction',
40
+ MIGRATION_ATTEMPTS: 'migronaut.migration.attempts',
41
+ JOB_ID: 'migronaut.job.id',
42
+ JOB_GROUP_ID: 'migronaut.job.group_id',
40
43
  BACKGROUND_NAME: 'migronaut.background.name',
41
44
  BACKGROUND_OUTCOME: 'migronaut.background.outcome',
42
45
  BACKGROUND_RESULT: 'migronaut.background.result',