@rotorsoft/act-ops
v0.2.6
Published
Operational primitives for act apps and act-independent receivers — idempotency, retry budgets, poison-message classifiers
Maintainers
Readme
@rotorsoft/act-ops
Operational primitives for @rotorsoft/act apps and act-independent receivers — idempotency, retry budgets, poison-message classification.
Why this package
Operational concerns that pair with Act — receiver-side idempotency, retry-budget sizing, poison-message classification — show up on both sides of the wire. The inline webhook reaction from @rotorsoft/act-http enforces dedup with an auto-derived Idempotency-Key; the cooperative receiver on the other end has to actually honor it. When the receiver is itself an Act app, it can reach for the same primitives the framework uses internally. When the receiver is something else — a Kafka consumer, an Express endpoint, a queue worker — it should be able to speak the same contract without dragging the orchestrator along.
@rotorsoft/act-ops is the home for those primitives. The package has no dependency on @rotorsoft/act and no peer dep. A service that processes forwarded events off a bus can install @rotorsoft/act-ops alone and get a port, an in-memory reference implementation, and the sizing helpers that pair with it. The cost of speaking the contract is one small library, not the full framework.
Durable adapters (Postgres, Redis, etc.) ship in their own packages and depend on the port declared here. @rotorsoft/act-http's receiver middleware consumes the port too. The split mirrors the existing store/cache/logger pattern: the contract lives in one small lib; adapters slot in around it.
Installation
pnpm add @rotorsoft/act-opsNo peer dependencies. Drop it next to whatever framework — or no framework at all — your receiver runs on.
Quick start
import { InMemoryIdempotencyStore } from "@rotorsoft/act-ops/idempotency";
// Either set ttlMs explicitly…
const dedup = new InMemoryIdempotencyStore({
ttlMs: 24 * 60 * 60 * 1000, // 24h covers any reasonable retry envelope
maxEntries: 50_000,
});
// …or describe the sender's retry profile and let the store size
// the dedup window correctly (per-retry backoff + per-attempt
// timeouts × default 4× safety factor; jitter honored).
const sized = new InMemoryIdempotencyStore({
retryProfile: {
maxRetries: 5,
backoff: { strategy: "exponential", baseMs: 200, maxMs: 30_000 },
timeoutMs: 2_000,
},
maxEntries: 50_000,
});
// In any receiver — tRPC, Express, Fastify, Hono, a Kafka consumer, …
const key = extractIdempotencyKeyFromHeaders(req);
const fresh = dedup.claim(key); // tentative reservation
if (!fresh) return replyDedupedWithoutSideEffects();
try {
await applyEventToAggregate(event);
await dedup.commit(key); // success — later retries dedup
} catch (err) {
await dedup.release(key); // transient — allow re-processing
throw err;
}claim acquires the tentative right to process this key — atomically, returning whether the caller won. commit promotes that reservation to durable on success; release drops it on a transient failure so the sender's retry re-processes instead of being deduped into a silent success. The claim verb mirrors Store.claim's lease semantic from @rotorsoft/act: there, competing workers race for the right to drain a stream; here, competing requests race for the right to be the canonical first-time delivery for an Idempotency-Key. One caller wins the tentative claim (dedups any concurrent duplicate mid-flight); the caller then commits or releases based on the handler's outcome.
The in-memory implementation is sync; durable adapters (Postgres, Redis) return a Promise<boolean> — the port's union return type covers both, so the call site is identical.
The retryProfile option captures the math that every receiver otherwise computes by hand and many get wrong — the dedup window has to outlast the sender's full retry envelope, otherwise a key expires before the sender finishes retrying and the side effect runs twice. Pass the sender's { maxRetries, backoff?, timeoutMs } and the store sizes the window for you. The full math (per-retry sums per strategy, jitter worst-case 1.5×, default 4× safety factor) is documented inline on RetryProfile and worked through in the external integration guide.
Subpath layout
The package follows a subpath-export-per-domain shape, matching @rotorsoft/act-http. All idempotency primitives ship from @rotorsoft/act-ops/idempotency; future domains (poison-message classifiers, retry-budget helpers, …) will land on their own subpaths (@rotorsoft/act-ops/poison, @rotorsoft/act-ops/retry). Each subpath is its own ESM/CJS entry — pay for what you import.
API — @rotorsoft/act-ops/idempotency
IdempotencyStore— the contract. Two-phase:claim(key, now?): boolean | Promise<boolean>reserves a key tentatively (returnstruewhen fresh,falsewhen already present), then the caller confirms the outcome withcommit(key, now?)(promote to durable — later retries dedup) orrelease(key)(drop the tentative claim so a retry re-processes; a no-op once committed). The tentative claim still dedups a concurrent duplicate mid-flight; the split is what stops a transient handler failure from claiming-then-losing the delivery. Implementations should preserve committed records for at least the sender's full retry envelope.InMemoryIdempotencyStore— bounded LRU + TTL reference implementation. Single-process only; for multi-process receivers swap for a durable adapter (Postgres unique index, RedisSET NX, …) without changing the call site.InMemoryIdempotencyStoreOptions—{ ttlMs?, retryProfile?, maxEntries? }. SetttlMsdirectly, or passretryProfileand the store derives the safe window. When both are supplied,ttlMswins.RetryProfile—{ maxRetries, backoff?, timeoutMs, safetyFactor? }. The sender's retry shape, used byInMemoryIdempotencyStoreto derive its window. Thebackofffield is typed structurally inline so it accepts the framework'sBackoffOptionswithout a cast — but this package doesn't reinvent or re-export the type, preserving the zero-act-dep property.minSafeTtl(profile): number— the derivation the in-memory store uses internally, exported for adapter authors. Durable adapters (e.g. the futurePostgresIdempotencyStore) import this from@rotorsoft/act-ops/idempotencyso every adapter applies the same math when given aretryProfileoption. Application developers don't typically call this directly — they passretryProfileto the store and let the store call it.
Why a Store and not a Cache
The doc colloquially calls these "cache shapes," but structurally this is authoritative storage. Losing a dedup record causes a duplicate side effect — paying the same invoice twice, sending the same email twice, opening the same incident twice — not just a rebuild from a source of truth. In this codebase Cache is reserved for "rebuildable" state (the snapshot cache); Store is reserved for authoritative state. The idempotency contract sits in the second bucket. Hence IdempotencyStore.
The practical implication: when you swap InMemoryIdempotencyStore for a durable adapter at deploy time, the durable adapter's persistence is the load-bearing property — not its hit rate.
Compatibility
- Node: >=22.18.0
- Peer: none — does not depend on
@rotorsoft/act. Designed to be installed by non-Act receivers (bus consumers, HTTP endpoints) and by Act apps alike. - Module formats: ESM (
import) and CJS (require). No side effects.
Stability
Public API governed by the Act Stability Charter once primitives stabilise. While the package is in 0.x everything is provisional — the surface freezes when the first 1.0.0 cuts. The milestone tracker is milestone 1.1.
Related packages
- @rotorsoft/act — the framework. Reactions and the inline
webhookhelper produce the requests this package's primitives are designed to dedup on the receiving side. - @rotorsoft/act-http — outbound
webhook(setsIdempotency-Key) and, once #744 lands, the framework-agnostic receiver middleware that consumes theIdempotencyStoreport declared here. - @rotorsoft/act-pg / @rotorsoft/act-sqlite — store adapters today; future durable
IdempotencyStoreadapters will follow the same packaging pattern. - @rotorsoft/act-tck — Store / Cache / Logger conformance kit. An
IdempotencyStoreconformance suite will land here in 1.2 alongside a durable adapter.
Documentation
- External integration patterns — inline
webhookvs forwarded bus, the receiver-side idempotency contract this package implements, the recovery loop. - ACT-1110 helper extraction & act-ops foundation — milestone 1.1 tracker for the helpers landing here over the next few PRs.
License
MIT
