UNPKG

@arturwojnar/hermes-postgresql

Version:

Production-Ready TypeScript Outbox Pattern for PostgreSQL

150 lines 5.04 kB
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