@eristack/ai-knowledge
Version:
Eristack knowledge pack for AI agents: recommend packages first, load the right Intent skills, and keep catalog facts in sync
84 lines (57 loc) • 3.21 kB
Markdown
---
title: Idempotency and outbox
description: One guide for duplicate POSTs, serverless races, ledger retries, and async side effects.
---
# Idempotency and outbox
One guide for duplicate POSTs, serverless races, ledger retries, and async side effects.
## Four layers (office pattern)
1. **Client** — one `Idempotency-Key` per submit intent (`@eristack/idempotency/client`).
2. **HTTP replay** — `@eristack/idempotency` scoped store + optional Express `wrapIdempotentHandler`.
3. **Domain** — app `UNIQUE (tenant_id, idempotency_key)` on create (PO, …).
4. **Async** — `@eristack/outbox` in the same TX; workers call comms/payment with derived keys.
**Uploads:** `@eristack/file-manager` optional `clientUploadId` per namespace — `UNIQUE(namespace, client_upload_id)` so presign/complete retries return the same `fileId`.
**Horizon A:** ERP demo Backseat seed exposes `POST /purchase-orders` with body `idempotencyKey` (collection scan dedup). See `examples/express` + `examples/react` for Drizzle guard + client fetch.
## Platform guard order
`rate-limit` → `api-key` → `idempotency` → handler. See `@eristack/ai-knowledge#party-and-platform-compose`.
## HTTP idempotency (production)
```ts
import { createDrizzleIdempotencyStore, createIdempotencyTables } from "@eristack/idempotency/drizzle";
import { createIdempotencyGuard, hashIdempotencyRequest } from "@eristack/idempotency";
import { wrapIdempotentHandler } from "@eristack/idempotency/express";
const store = createDrizzleIdempotencyStore({ db, tables: createIdempotencyTables("pgsql") });
const guard = createIdempotencyGuard({ store, defaultLeaseMs: 60_000 });
app.post(
"/api/purchase-orders",
wrapIdempotentHandler({ guard }, async (req) => {
// insert PO + UNIQUE(tenant_id, idempotency_key)
return { id: po.id };
}),
);
```
- Scoped key: `tenantId + route scope + Idempotency-Key`.
- `requestHash` mismatch → 409 (same key, different body).
- Pending **lease** expires so serverless retries can reclaim.
Memory store: **tests only**.
## Ledger append dedup
Implemented in `@eristack/hash-chained-ledger` — optional `idempotencyKey` on `append`.
| Package | Pass keys |
| --- | --- |
| `@eristack/stock-movement` | `idempotencyKey` on `append` |
| `@eristack/financial-ledger` | `idempotencyKey` on `post` |
| `@eristack/valuations` | `operationId` → `${op}:qty` / `${op}:value` |
Drizzle: `UNIQUE(chain_id, idempotency_key)`.
## Side-effect hubs
**Comms / payment-manager:** durable row (`queued` / `pending`) **before** vendor/gateway; handle UNIQUE races; webhook events deduped by provider/gateway event id.
**Outbox:** enqueue with `idempotencyKey`; worker handlers:
```ts
await comms.send({ ..., idempotencyKey: `outbox:${message.id}` });
```
## Explicit non-goals
- `@eristack/epoch` bump — not Idempotency-Key.
- `@eristack/pbac` — policy only; use TX + qty checks.
- `@eristack/jwt-auth` refresh rotation — separate from HTTP keys.
## Recipes
- `platform-api-guard` — middleware order
- `idempotency-http-replay` — HTTP store + adapters
- `ledger-idempotent-append` — HCL + stock/financial/valuations
- `reliable-side-effects-outbox` — outbox + comms/payment