@krizaka/outbox

中文文档
0.1.4发布于 2026/11/01Apache-2.0TypeScriptES Module构建来源已验证

The transactional outbox of every Krizaka product: an event written in the caller's transaction, delivered after the commit at least once by leased workers, with doubling retries, dead letters and an idempotent-consumer helper. No broker, no database clie

原始使用文档

@krizaka/outbox

The transactional outbox of every Krizaka product. An event is written in the caller's transaction, so it exists exactly when the work committed, and is delivered after the commit, at least once, by workers that lease a batch. No broker: PostgreSQL and FOR UPDATE SKIP LOCKED share the work between instances. It replaces Akinaza's mail_outbox (whose flushOutbox nobody called) and the analytics outboxes the products carried; it is the TypeScript twin of krizaka-messaging's krizaka_outbox (the envelope is id, type, occurredAt, payload).

The guarantee is at least once. Consumers must be idempotent on the message id. A claim is exclusive while its lease is valid. A worker that delivers and then crashes before complete lets the lease expire, and the message is delivered again. The outbox never promises « exactly once ».

import { emitMigration, transaction } from "@krizaka/db";
import { consumeOnce, createOutbox, outboxSchema, runWorker } from "@krizaka/outbox";
import { readOutboxSettings } from "@krizaka/outbox/settings";

emitMigration(outboxSchema, 1, { tables: {} }); // commit the SQL once, in your migrations: outbox.message and outbox.processed

const outbox = createOutbox({ clock: { now: () => new Date() }, settings: () => readOutboxSettings((key) => settings.get(key)) });

// 1. in the use case: the message commits or rolls back with the work
await transaction(connect, async (tx) => {
  const redemption = await coupons.redeem(tx, { code, accountId });
  await outbox.append(tx, { topic: "credits", type: "evt.coupon.redeemed.v1", payload: { redemptionId: redemption.id }, id: redemption.eventId });
});

// 2. a worker the product starts (Next.js instrumentation.ts); the package has no timer, you pass `sleep`
const stop = new AbortController();
void runWorker({
  outbox, query: pool.query.bind(pool) /* a pool-level Query */, owner: `${hostname}:${process.pid}`, topic: "credits", signal: stop.signal,
  sleep: (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)),
  deliver: async (message) => { /* throw to retry */ },
});

// 3. a consumer with an effect in the database applies it once per message id, in the effect's transaction
await transaction(connect, (tx) => consumeOnce(tx, "credits", message.id, clock.now(), () => credits.grant(tx, …)));
  • append(tx, { topic, type, payload, id?, availableAt? }) takes the transaction's Query first. A given id makes the append idempotent (the same fact twice is one message). type is a routing key, evt.<module>.<fact>.v1 like Java's.
  • claim(query, { owner, topic? }) is UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) and sets the lease. complete and fail(error) act only for the lease's owner (a worker that lost its lease cannot overwrite the new holder). fail backs off — availableAt = now + backoffSeconds × 2^(attempts−1), capped — and after maxAttempts the message is dead (kept, with its last error; retryDead gives it another life). A message whose last allowed attempt's lease expired with no answer is dead too, not delivered a ninth time.
  • runWorker loops drainOnce (claim a batch, deliver in order, record each outcome), sleeps through the sleep you pass when a round found nothing, and ends when the signal is aborted. Run drainOnce from a scheduler instead if you prefer cron to a loop.
  • Settings (@krizaka/outbox/settings, needs @krizaka/settings and zod): outbox.batchSize (50, 1–500), outbox.leaseSeconds (60, 10–3600: longer than your slowest delivery), outbox.maxAttempts (8, 1–50), outbox.backoffSeconds (30, 1–3600), outbox.backoffMaxSeconds (3600), outbox.retentionDays (7). purge removes delivered and dead messages older than the retention: call it from the product's job.
  • Tables (outboxSchema, Postgres schema outbox): message (id, topic, event_type, payload, created_at, available_at, attempts, leased_until, lease_owner, delivered_at, dead_at, last_error) and processed (consumer, message_id), which consumeOnce uses: the marker and the effect commit in the same transaction, so a crash leaves neither and a redelivery finds the marker.
  • outboxStoreContract (from @krizaka/outbox/testkit) is the contract every store runs; memoryOutboxStore() is for unit tests.

版本与发布记录

@krizaka/outbox 共发布 6 个版本,首次发布于 2026/10/27;过去 12 个月发布了 6 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.1.4latest2026/11/011 天前
0.1.32026/10/304 天前
0.1.22026/10/295 天前
0.1.12026/10/277 天前
0.1.02026/10/277 天前
0.0.0-stage2026/10/277 天前

关于 @krizaka/outbox 的常见问题

@krizaka/outbox 是什么?

每个Krizaka产品的事务发件箱: 在调用者的事务中写入的事件,在提交后至少由租用的工作人员交付一次,具有加倍的重试,死信和一个幂等的消费者助手。没有经纪人,没有数据库客户

如何安装 @krizaka/outbox?

在项目目录运行 npm install @krizaka/outbox,或使用 pnpm add / yarn add / bun add。

@krizaka/outbox 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@krizaka/outbox 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@krizaka/outbox 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@krizaka/outbox 可以商用吗?

@krizaka/outbox 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@krizaka/outbox 还在维护吗?

最新版本 0.1.4 发布于 2026/11/01(1 天前);至今共发布 6 个版本,其中过去 12 个月发布了 6 个;首次发布于 2026/10/27。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。