mongochangestream
Version:
Sync MongoDB collections via change streams into any database.
186 lines (185 loc) • 5.25 kB
JavaScript
import lodash from 'lodash';
import { MongoServerError, } from 'mongodb';
import { map, walkEach } from 'obj-walker';
import retry from 'p-retry';
const { set } = lodash;
export const setDefaults = (keys, val) => {
const obj = {};
for (const key of keys) {
obj[key] = val;
}
return obj;
};
export const generatePipelineFromOmit = (omit) => {
const fields = omit.flatMap((field) => [
`fullDocument.${field}`,
`updateDescription.updatedFields.${field}`,
]);
return [{ $unset: fields }];
};
/**
* Dotted path updates like { $set: {'a.b.c': 'foo'} } result in the following:
* ```ts
* {
* updatedDescription: {
* updateFields: {
* 'a.b.c': 'foo'
* }
* }
* }
* ```
* Therefore, to remove 'a.b' we have to walk the `updateFields` object
* and unset the omitted paths.
*/
export const omitFieldsForUpdate = (omittedPaths, event) => {
const shouldOmit = (path) => omittedPaths.find((omittedPath) => path === omittedPath || path.startsWith(`${omittedPath}.`));
if (event.updateDescription.updatedFields) {
map(event.updateDescription.updatedFields, (node) => {
const fullPath = node.path.join('.');
if (!shouldOmit(fullPath)) {
return node.val;
}
}, { modifyInPlace: true });
}
if (event.updateDescription.removedFields) {
const removedFields = event.updateDescription.removedFields.filter((removedPath) => !shouldOmit(removedPath));
set(event, 'updateDescription.removedFields', removedFields);
}
};
export const getCollectionKey = (collection) => `${collection.dbName}:${collection.collectionName}`;
export const traverseSchema = (x) => x.properties || (x.items && { _items: x.items });
const usedSchemaFields = [
'bsonType',
'properties',
'additionalProperties',
'items',
'enum',
];
/**
* Remove unused schema fields
*/
export const removeUnusedFields = (schema) => {
const walkFn = ({ val }) => {
for (const key in val) {
if (!usedSchemaFields.includes(key)) {
delete val[key];
}
}
};
return walkEach(schema, walkFn, {
traverse: traverseSchema,
modifyInPlace: true,
});
};
export function when(condition, fn) {
return function (x) {
return condition ? fn(x) : x;
};
}
const oplogErrorCodeNames = [
'ChangeStreamHistoryLost',
'InvalidResumeToken',
'FailedToParse',
];
/**
* Check if error message indicates a missing or invalid oplog entry.
*/
export const missingOplogEntry = (error) => {
if (error instanceof MongoServerError) {
return oplogErrorCodeNames.includes(error?.codeName ?? '');
}
return false;
};
/**
* Creates a delayed function that only invokes fn at most once every ms.
* The first invocation will be the one that gets executed. Subsequent calls
* to the delayed function will be dropped if there is a pending invocation.
*
* The delayed function comes with a cancel method to cancel the delayed fn
* invocation and a flush method to immediately invoke it.
*/
export const delayed = (fn, ms) => {
let timeoutId;
let scheduled = false;
let callback;
const delayedFn = (...args) => {
if (!scheduled) {
scheduled = true;
callback = () => {
fn(...args);
scheduled = false;
};
timeoutId = setTimeout(callback, ms);
}
};
delayedFn.cancel = () => {
clearTimeout(timeoutId);
scheduled = false;
};
delayedFn.flush = () => {
if (scheduled) {
delayedFn.cancel();
callback();
}
};
return delayedFn;
};
/**
* Convert an initial scan document to a change stream insert document
* suitable for downstream consumption. Note: not all fields are present,
* such as, _id (resume token).
*/
export const docToChangeStreamInsert = (collection) => {
const ns = { db: collection.dbName, coll: collection.collectionName };
return (doc) => ({
fullDocument: doc,
operationType: 'insert',
ns,
documentKey: { _id: doc._id },
});
};
/**
* Rename key, mutating the given document.
*/
const renameKey = (doc, key, newKey) => {
const temp = doc[key];
delete doc[key];
doc[newKey] = temp;
};
/**
* Rename keys, mutating the given document. Used in downstream libraries.
*/
export const renameKeys = (doc, keys) => {
for (const key in keys) {
if (key in doc) {
const newKey = keys[key];
renameKey(doc, key, newKey);
}
}
};
/**
* Wrap non-Error exceptions in an Error.
*/
export const castError = (err) => {
if (err instanceof Error) {
return err;
}
if (typeof err === 'string') {
return new Error(err);
}
return new Error('Unknown error', { cause: err });
};
/**
* Safely call retry, wrapping non-Error exceptions in an Error.
*/
export const safeRetry = (fn, options) => {
const input = async () => {
try {
return await fn();
}
catch (err) {
throw castError(err);
}
};
return retry(input, options);
};