@arturwojnar/hermes-mongodb
Version:
Production-Ready TypeScript Outbox Pattern for MongoDB
135 lines • 6.1 kB
JavaScript
import { CancellationPromise, addDisposeOnSigterm, assertDate, isNil, swallow } from '@arturwojnar/hermes';
import { ClientSession, ObjectId } from 'mongodb';
import { setTimeout } from 'node:timers/promises';
import { noop } from 'ts-essentials';
import { OutboxMessagesCollectionName } from './consts.js';
import { createChangeStream } from './createChangeStream.js';
import { ensureIndexes } from './ensureIndexes.js';
import { getConsumer } from './getConsumer.js';
import { generateVersionPolicies } from './versionPolicies.js';
export const createOutboxConsumer = (params) => {
const { client, db, publish: _publish } = params;
const partitionKey = params.partitionKey || 'default';
const saveTimestamps = params.saveTimestamps || false;
const _now = params.now;
const now = typeof _now === 'function'
? () => {
const value = _now();
assertDate(value);
return value;
}
: () => new Date();
const waitAfterFailedPublishMs = params.waitAfterFailedPublishMs || 1000;
const shouldDisposeOnSigterm = isNil(params.shouldDisposeOnSigterm) ? true : !!params.shouldDisposeOnSigterm;
const onDbError = params.onDbError || noop;
const onFailedPublish = params.onFailedPublish || noop;
const messages = db.collection(OutboxMessagesCollectionName);
const addMessage = async (event, partitionKey, session) => Array.isArray(event)
? await messages.insertMany(event.map((data) => ({
_id: new ObjectId(),
partitionKey,
occurredAt: new Date(),
data,
})), { session })
: await messages.insertOne({
_id: new ObjectId(),
partitionKey,
occurredAt: new Date(),
data: event,
}, { session });
let shouldStopPromise = CancellationPromise.resolved(undefined);
return {
async start() {
const { supportedVersionCheckPolicy, changeStreamFullDocumentValuePolicy } = await generateVersionPolicies(db);
supportedVersionCheckPolicy();
await ensureIndexes(db);
await shouldStopPromise;
shouldStopPromise = new CancellationPromise();
const consumer = await getConsumer(db, partitionKey);
const watchCursor = createChangeStream(changeStreamFullDocumentValuePolicy, messages, partitionKey, consumer.resumeToken);
const _waitUntilEventIsSent = async (event) => {
let published = false;
while (!watchCursor.closed) {
try {
await _publish(event);
published = true;
break;
}
catch (error) {
onFailedPublish(error);
await setTimeout(waitAfterFailedPublishMs);
continue;
}
}
return published;
};
const watch = async () => {
while (!watchCursor.closed) {
try {
const result = await Promise.race([shouldStopPromise, watchCursor.hasNext()]);
if (result === null) {
await watchCursor.close();
break;
}
if (result) {
const { _id: resumeToken, operationType, fullDocument: message, documentKey } = await watchCursor.next();
if (operationType !== 'insert') {
continue;
}
if (await _waitUntilEventIsSent(message.data)) {
if (saveTimestamps) {
await db
.collection(OutboxMessagesCollectionName)
.updateOne({ _id: message._id }, { $set: { sentAt: now() } });
await consumer.update(documentKey._id, resumeToken);
}
else {
await consumer.update(documentKey._id, resumeToken);
}
}
}
}
catch (error) {
onDbError(error);
await setTimeout(waitAfterFailedPublishMs);
}
}
};
watch()
.catch(console.error)
.finally(() => swallow(() => watchCursor.close()));
const stop = async function stop() {
if (!watchCursor.closed) {
shouldStopPromise.resolve(null);
await watchCursor.close();
}
};
if (shouldDisposeOnSigterm) {
addDisposeOnSigterm(stop);
}
return stop;
},
async publish(event, sessionOrCallback) {
if (sessionOrCallback instanceof ClientSession || !sessionOrCallback) {
await addMessage(event, partitionKey, sessionOrCallback);
}
else {
await client.withSession(async (session) => {
await session.withTransaction(async (session) => {
await sessionOrCallback(session, db, client);
await addMessage(event, partitionKey, session);
});
});
}
},
async withScope(scopeFn) {
return await client.withSession((session) => session.withTransaction(async (session) => {
const publish = async (event) => {
await addMessage(event, partitionKey, session);
};
return await scopeFn({ publish, session, client });
}));
},
};
};
//# sourceMappingURL=outbox.js.map