@arturwojnar/hermes-postgresql
Version:
Production-Ready TypeScript Outbox Pattern for PostgreSQL
150 lines • 5.04 kB
JavaScript
import { assert, literalObject } from '@arturwojnar/hermes';
import { convertLsnToBigInt, toLsn } from '../common/lsn.js';
import { getSlotName } from '../common/consts.js';
const OutboxConsumerStatuses = [
'INITIAL',
'CREATED',
'SUCCESS_PUBLISH',
'FAILED_PUBLISH',
'DELETED',
];
class OutboxConsumerStore {
_sql;
_consumerName;
_partitionKey;
_consumer = null;
constructor(_sql, _consumerName, _partitionKey = 'default') {
this._sql = _sql;
this._consumerName = _consumerName;
this._partitionKey = _partitionKey;
}
async load() {
const [consumer] = (await this._sql `
SELECT "id", "lastProcessedLsn", "status", "failedNextLsn", "nextLsnRedeliveryCount", "createdAt", "lastUpdatedAt" FROM "outboxConsumer"
WHERE "consumerName"=${this._consumerName}
AND "partitionKey"=${this._partitionKey}
`);
this._consumer = consumer;
return consumer;
}
async createOrLoad(data) {
const slotName = getSlotName(this._consumerName, this._partitionKey);
const restartLsnResults = await this._sql `SELECT * FROM pg_replication_slots WHERE slot_name = ${slotName};`;
const restartLsn = restartLsnResults?.[0]?.restart_lsn || '0/00000000';
await this._sql `
INSERT INTO "outboxConsumer" (
"consumerName",
"partitionKey",
"lastProcessedLsn",
"createdAt"
) VALUES (
${data.consumerName},
${data.partitionKey},
${restartLsn},
${data.createdAt}
)
ON CONFLICT ("consumerName", "partitionKey") DO NOTHING;
`;
return await this.load();
}
async update(consumerName, change, tx) {
assert(this._consumer);
const skipKeys = ['id', 'createdAt', 'consumerName', 'partitionKey'];
const keys = Object.keys(change)
.filter(([key]) => !skipKeys.includes(key))
.map((key) => key);
if (keys.length === 0) {
return this._consumer;
}
const sql = tx ? tx : this._sql;
await sql `
UPDATE "outboxConsumer"
SET ${sql(change, ...keys)}
WHERE "consumerName" = ${consumerName};
`;
Object.assign(this._consumer, change);
return this._consumer;
}
get consumer() {
return this._consumer;
}
get consumerName() {
return this._consumerName;
}
get lastProcessedLsn() {
assert(this._consumer);
const { status } = this._consumer;
if (status === 'INITIAL' || status === 'DELETED') {
return toLsn('0/00000000');
}
return toLsn(this._consumer.lastProcessedLsn);
}
get redeliveryCount() {
assert(this._consumer);
const { status } = this._consumer;
if (status === 'FAILED_PUBLISH') {
return this._consumer.nextLsnRedeliveryCount;
}
return 0;
}
}
class OutboxConsumerState {
_store;
constructor(_store) {
this._store = _store;
}
async createOrLoad(partitionKey = 'default') {
return await this._store.createOrLoad(literalObject({
status: 'CREATED',
id: 0,
consumerName: this._store.consumerName,
partitionKey,
createdAt: new Date(),
}));
}
async moveFurther(lastProcessedLsn, tx) {
const { consumer } = this._store;
assert(consumer);
if (consumer.status === 'DELETED' || consumer.status === 'INITIAL') {
return;
}
if (convertLsnToBigInt(lastProcessedLsn) <= convertLsnToBigInt(this._store.lastProcessedLsn)) {
console.error('outbox state invalid op');
return;
}
await this._store.update(this._store.consumerName, literalObject({
status: 'SUCCESS_PUBLISH',
lastUpdatedAt: new Date(),
lastProcessedLsn,
failedNextLsn: null,
nextLsnRedeliveryCount: 0,
}), tx);
}
async reportFailedDelivery(failedNextLsn, tx) {
const { consumer } = this._store;
assert(consumer);
if (consumer.status === 'DELETED' || consumer.status === 'INITIAL') {
return;
}
const nextLsnRedeliveryCount = consumer.status === 'CREATED' || consumer.status === 'SUCCESS_PUBLISH' ? 1 : consumer.nextLsnRedeliveryCount + 1;
await this._store.update(this._store.consumerName, literalObject({
status: 'FAILED_PUBLISH',
lastUpdatedAt: new Date(),
failedNextLsn,
nextLsnRedeliveryCount,
}), tx);
}
get data() {
return this._store.consumer;
}
get lastProcessedLsn() {
assert(this._store);
return this._store.lastProcessedLsn;
}
get redeliveryCount() {
assert(this._store);
return this._store.redeliveryCount;
}
}
export { OutboxConsumerState, OutboxConsumerStatuses, OutboxConsumerStore };
//# sourceMappingURL=OutboxConsumerState.js.map