@andrew_l/mongo-transaction
Version:
Manages side effects in MongoDB transactions, rollback on failure and preventing duplicates on retries.
365 lines (364 loc) • 12.1 kB
JavaScript
import { assert, asyncForEach, catchError, deepDefaults, defer, env, has, isEqual, isFunction, isPromise, logger, noop, retryOnError } from "@andrew_l/toolkit";
import { createContext, hasInjectionContext, withContext } from "@andrew_l/context";
import { MongoTransactionError } from "mongodb";
const [injectMongoSession, provideMongoSession] = createContext("withMongoTransaction");
function useMongoSession() {
return hasInjectionContext() ? injectMongoSession(null) : null;
}
function onMongoSessionCommitted(...args) {
let session;
let fn;
if (args.length === 2) [session, fn] = args;
else {
session = injectMongoSession();
fn = args[0];
}
const q = defer();
const onEnded = () => {
if (!session.transaction.isCommitted) return q.resolve(void 0);
try {
const result = fn();
if (isPromise(result)) result.then((r) => q.resolve(r)).catch(q.reject);
else q.resolve(result);
} catch (err) {
q.reject(err);
}
};
session.once("ended", onEnded);
const cancel = () => {
session.off("ended", onEnded);
};
return {
promise: q.promise,
cancel
};
}
const [injectTransactionScope, provideTransactionScope] = createContext([
"withTransaction",
"withTransactionControlled",
"withMongoTransaction"
]);
var TransactionScope = class {
fn;
log = logger("TransactionScope");
_active = false;
error;
result;
run;
hooks = {
committed: {
byCursor: [],
cursor: 0
},
effects: {
byCursor: [],
cursor: 0
},
rollbacks: {
byCursor: [],
cursor: 0
}
};
constructor(fn) {
this.fn = fn;
const scope = this;
this.run = function(...args) {
const self = this === scope ? void 0 : this;
return scope._run(self, ...args);
};
}
get active() {
return this._active;
}
_run(self, ...args) {
if (this._active) return Promise.reject(/* @__PURE__ */ new Error("Cannot commit while transaction active."));
this.reset();
this._active = true;
return Promise.resolve().then(() => withContext(() => {
provideTransactionScope(this);
return catchError(() => this.fn.call(self, ...args));
})()).then(({ 0: cbError, 1: cbResult }) => {
if (cbError) this.error = cbError;
else return effectsApply(this, "post").then((applyError) => {
if (applyError) this.error = applyError;
this.result = cbResult;
});
}).finally(() => {
this._active = false;
});
}
commit() {
if (this._active) return Promise.reject(/* @__PURE__ */ new Error("Cannot commit while transaction active."));
if (this.error) return Promise.reject(this.error);
return asyncForEach(this.hooks.committed.byCursor, (h) => catchError(h.callback), { concurrency: 4 }).then(() => {
this.reset();
this.clean();
});
}
rollback() {
if (this._active) return Promise.reject(/* @__PURE__ */ new Error("Cannot rollback while transaction active."));
return effectsCleanup(this).then((error) => {
if (error) return Promise.reject(error);
return asyncForEach(this.hooks.rollbacks.byCursor, (h) => catchError(h.callback), { concurrency: 4 }).then(() => {
this.reset();
this.clean();
});
});
}
reset() {
assert.ok(!this._active, "Cannot reset while transaction active.");
this.hooks.effects.cursor = 0;
this.hooks.committed.cursor = 0;
this.hooks.rollbacks.cursor = 0;
this.result = void 0;
this.error = void 0;
}
clean() {
assert.ok(!this._active, "Cannot clean while transaction active.");
this.hooks.effects = {
byCursor: [],
cursor: 0
};
this.hooks.committed = {
byCursor: [],
cursor: 0
};
this.hooks.rollbacks = {
byCursor: [],
cursor: 0
};
}
};
function createTransactionScope(fn) {
return new TransactionScope(fn);
}
function effectsApply(scope, reason = "no reason") {
let error;
const onComplete = (err) => void (error = err || error);
return asyncForEach(scope.hooks.effects.byCursor, (effect) => applyEffect(scope, effect, reason).then(onComplete), { concurrency: 4 }).then(() => error);
}
function effectsCleanup(scope, reason = "no reason") {
let error;
const onComplete = (err) => void (error = err || error);
return asyncForEach(scope.hooks.effects.byCursor, (effect) => cleanupEffect(scope, effect, reason).then(onComplete), { concurrency: 4 }).then(() => error);
}
function applyEffect(scope, effect, reason = "no reason") {
if (effect.cleanup) return Promise.resolve(void 0);
scope.log.debug("Effect name = %s, flush = %s, apply by %s", effect.name, effect.flush, reason);
return Promise.resolve().then(() => catchError(effect.setup)).then(({ 0: err, 1: effectResult }) => {
if (err) {
!env.isTest && scope.log.error("Effect name = %s, flush = %s apply error", effect.name, effect.flush, err);
return err;
}
effect.cleanup = isFunction(effectResult) ? effectResult : noop;
});
}
function cleanupEffect(scope, effect, reason = "no reason") {
if (!effect.cleanup) return Promise.resolve(void 0);
scope.log.debug("Effect name = %s, flush = %s, cleanup by %s", effect.name, effect.flush, reason);
return Promise.resolve().then(() => catchError(effect.cleanup)).then(({ 0: err }) => {
if (err) {
!env.isTest && scope.log.error("Effect name = %s, flush = %s, cleanup error", effect.name, effect.flush, err);
return err;
}
effect.cleanup = void 0;
});
}
function useTransactionEffect(setup, options = {}) {
const scope = injectTransactionScope();
const { cursor, byCursor } = scope.hooks.effects;
const effectConfig = byCursor[cursor] ?? {};
const flush = options?.flush || "pre";
const name = options?.name || `Effect #${cursor + 1}`;
const dependencies = options.dependencies ?? [];
const prevDependencies = effectConfig.dependencies;
byCursor[cursor] = {
...effectConfig,
dependencies,
flush,
name,
setup
};
if (dependencies && prevDependencies) {
if (!isEqual(dependencies, prevDependencies)) {
scope.log.debug("Effect name = %s, flush = %s, caused by dependencies", name, flush, {
prevDependencies,
newDependencies: dependencies
});
return scheduleEffect(scope, cursor).then(() => {
scope.hooks.effects.cursor++;
});
}
} else {
scope.log.debug("Effect name = %s, flush = %s, caused by missing dependencies", name, flush);
return scheduleEffect(scope, cursor).then(() => {
scope.hooks.effects.cursor++;
});
}
return Promise.resolve();
}
function scheduleEffect(scope, cursor) {
const { byCursor } = scope.hooks.effects;
const effect = byCursor[cursor];
return cleanupEffect(scope, effect, "schedule pre").then((cleanupErr) => {
if (cleanupErr) return Promise.reject(cleanupErr);
if (effect.flush === "pre") return applyEffect(scope, effect, "schedule pre").then((err) => {
if (err) return Promise.reject(err);
});
});
}
function onCommitted(callback, dependencies) {
const scope = injectTransactionScope();
const { cursor, byCursor } = scope.hooks.committed;
const config = byCursor[cursor];
if (dependencies && config?.dependencies) {
if (!isEqual(dependencies, config.dependencies)) {
scope.log.debug("OnCommitted caused by dependencies", {
prevDependencies: config.dependencies,
newDependencies: dependencies,
cursor
});
byCursor[cursor] = {
callback,
dependencies
};
}
} else {
scope.log.debug("OnCommitted caused by missing dependencies", { cursor });
byCursor[cursor] = {
callback,
dependencies
};
}
scope.hooks.committed.cursor++;
return () => {
byCursor[cursor].callback = noop;
};
}
function onRollback(callback, dependencies) {
const scope = injectTransactionScope();
const { cursor, byCursor } = scope.hooks.rollbacks;
const config = byCursor[cursor];
if (dependencies && config?.dependencies) {
if (!isEqual(dependencies, config.dependencies)) {
scope.log.debug("OnCommitted caused by dependencies", {
prevDependencies: config.dependencies,
newDependencies: dependencies,
cursor
});
byCursor[cursor] = {
callback,
dependencies
};
}
} else {
scope.log.debug("OnCommitted caused by missing dependencies", { cursor });
byCursor[cursor] = {
callback,
dependencies
};
}
scope.hooks.committed.cursor++;
return () => {
byCursor[cursor].callback = noop;
};
}
function isTransactionAborted(transaction) {
return transaction?.state === "TRANSACTION_ABORTED";
}
function isTransactionCommittedEmpty(transaction) {
return transaction?.state === "TRANSACTION_COMMITTED_EMPTY";
}
function isMongoClientLike(value) {
return has(value, ["startSession"]) && isFunction(value.startSession);
}
const DEF_SESSION_OPTIONS = Object.freeze({ defaultTransactionOptions: {
readPreference: "primary",
readConcern: { level: "local" },
writeConcern: { w: "majority" }
} });
function withMongoTransaction(connectionOrOptions, maybeFn, maybeOptions) {
const { connection: connectionValue, fn, sessionOptions = {}, timeoutMS } = prepareOptions(connectionOrOptions, maybeFn, maybeOptions);
return function(...args) {
return Promise.resolve().then(() => isFunction(connectionValue) ? connectionValue() : connectionValue).then((connection) => connection.startSession(sessionOptions)).then((session) => {
const scope = createTransactionScope(function(...args) {
provideMongoSession(session);
return fn.call(this, session, ...args);
});
const timeoutAt = timeoutMS ? Date.now() + timeoutMS : 0;
const timeoutError = new MongoTransactionError("Transaction client-side timeout");
return catchError(() => session.withTransaction(() => {
if (timeoutAt && timeoutAt < Date.now()) return Promise.reject(timeoutError);
return Promise.resolve().then(() => scope.run.apply(this, args)).then(() => {
if (scope.error) return Promise.reject(scope.error);
});
})).then(({ 0: transactionError, 1: transactionResult }) => {
const { result } = scope;
return session.endSession().catch(noop).then(() => {
if (transactionResult === void 0 && isTransactionCommittedEmpty(session.transaction)) {} else if (isTransactionAborted(session.transaction) && transactionResult === void 0 && transactionError === void 0) transactionError = new MongoTransactionError("Transaction is explicitly aborted");
if (transactionError) return scope.rollback().then(() => Promise.reject(transactionError));
return scope.commit().then(() => result);
});
});
});
};
}
function prepareOptions(connectionOrOptions, maybeFn, maybeOptions) {
let options;
if (isFunction(connectionOrOptions) || isMongoClientLike(connectionOrOptions)) options = {
...maybeOptions || {},
connection: connectionOrOptions,
fn: maybeFn
};
else options = connectionOrOptions;
return deepDefaults(options, { sessionOptions: DEF_SESSION_OPTIONS });
}
function withTransaction(fn, { beforeRetryCallback, shouldRetryBasedOnError = () => true, maxAttempts, maxRetriesNumber = 5, delayFactor = 0, delayMaxMs = 1e3, delayMinMs = 100 } = {}) {
return function(...args) {
const scope = createTransactionScope(fn);
return retryOnError({
beforeRetryCallback,
shouldRetryBasedOnError,
maxAttempts,
maxRetriesNumber,
delayFactor,
delayMaxMs,
delayMinMs
}, () => {
return Promise.resolve().then(() => scope.run.apply(this, args)).then(() => {
if (scope.error) return Promise.reject(scope.error);
});
})().catch(noop).then(() => {
const { error, result } = scope;
if (error) return scope.rollback().then(() => Promise.reject(error));
return scope.commit().then(() => result);
});
};
}
function withTransactionControlled(fn) {
const scope = createTransactionScope(fn);
const controlled = {
run(...args) {
const self = this === controlled ? void 0 : this;
return scope.run.apply(self, args);
},
commit() {
return scope.commit();
},
rollback() {
return scope.rollback();
},
get active() {
return scope.active;
},
get result() {
return scope.result;
},
get error() {
return scope.error;
}
};
return controlled;
}
export { onCommitted, onMongoSessionCommitted, onRollback, useMongoSession, useTransactionEffect, withMongoTransaction, withTransaction, withTransactionControlled };
//# sourceMappingURL=index.mjs.map