Lease-aware idempotency, webhook inbox, and reconciliation contracts live in @paykernel/store-contracts. Production adapters implement these interfaces; inject stores at the app layer. Core and the webhook engine do not depend on a specific database.
Version 0.1.0 — published on npm as @paykernel/store-contracts. Install: bun add @paykernel/store-contracts.
Install
bun add @paykernel/store-contractsZero runtime workspace dependencies.
import {
StoreLeaseLostError,
assertStorageAdapterManifest,
type LeaseAwareIdempotencyStore,
type WebhookInboxStore,
type ReconciliationStore,
type StorageAdapterManifest,
} from "@paykernel/store-contracts";Three contracts (not one bag)
Adapters implement only the contracts they claim. Do not fold all three into a mixed “storage” object.
| Contract | Type | Acquire | Token-gated mutators |
|---|---|---|---|
| Idempotency | IdempotencyStore / LeaseAwareIdempotencyStore |
reserve |
renew, complete, markIndeterminate |
| Webhook inbox | WebhookInboxStore |
claim |
renew, complete, fail |
| Reconciliation | ReconciliationStore |
schedule then claim |
renew, complete, fail, markManualReview |
Prefer LeaseAwareIdempotencyStore when @paykernel/core is also in scope — core 0.x IdempotencyStore is a different API (get / set / optional reserve). Never mix the two.
Result kinds
| Store | Discriminants |
|---|---|
Idempotency reserve |
acquired | already_completed | in_progress | indeterminate | fingerprint_conflict (classify completed / indeterminate before fingerprint_conflict) |
Webhook claim |
acquired | already_completed | in_progress | payload_hash_conflict | duplicate_failed | not_available |
Reconciliation claim |
acquired | not_due | in_progress | already_terminal | not_found |
Reconciliation schedule |
scheduled | already_exists |
listDue must soft-release expired claimed jobs so poll workers rediscover abandoned work. Key-addressed reclaim after expiry is not enough.
Atomic claims
reserve / claim must be a single engine-level claim (conditional INSERT/UPDATE, Redis Lua, Durable Object transactional write). Concurrent workers serialize in the storage engine.
// FORBIDDEN multi-process claim
const row = await store.get(key);
if (!row || expired(row)) await store.set(key, claimedRow);withTransaction is an optional helper. It is not a substitute for atomic reserve/claim. Do not await provider HTTP inside a synchronous SQLite / Durable Object transaction callback.
Dual fencing
Every claimable record has key, status, leaseOwner, leaseToken, leaseExpiresAt, attempts, generation, createdAt, updatedAt.
| Field | Rule |
|---|---|
generation |
Monotonic; increment on every successful reserve / claim / renew that issues a new lease |
leaseToken |
Unguessable opaque string; after reclaim or renew the prior token must fail |
complete / renew require an unexpired lease. Webhook fail succeeds after expiry when the token still matches status === "claimed" (hang/timeout still records the attempt). markIndeterminate may park an unreclaimed reserved row near/at expiry; after reclaim the prior token is fenced.
Treat lease_lost as “another worker owns the work” — not as a payment failure.
Indeterminate (A4)
When a mutation’s outcome is uncertain, call markIndeterminate while you still hold the lease. Do not invent completed or failed.
After status is indeterminate:
reservereturnskind: "indeterminate"and must not issue a new lease- Automatic replay is forbidden
deleteExpiredmust not remove indeterminate rows by default- Resolve with lookup +
decideReconciliationPolicy— do notcreatePaymentagain afteroutcome === "indeterminate"orreconciliationRequired
Inbox vs webhook HTTP
handleWebhook verifies and normalizes. It does not claim, lease, or set HTTP status. HTTP mapping lives in @paykernel/integration-http (mapInboxOutcome), not in @paykernel/webhooks.
Error taxonomy
STORE_ERROR_CODES and subclasses from @paykernel/store-contracts:
| Code | Subclass | Default retryable |
|---|---|---|
unavailable |
StoreUnavailableError |
true |
conflict |
StoreConflictError |
false |
lease_lost |
StoreLeaseLostError |
false |
timeout |
StoreTimeoutError |
true |
serialization_failure |
StoreSerializationFailureError |
true |
invalid_schema |
StoreInvalidSchemaError |
false |
unsupported_feature |
StoreUnsupportedFeatureError |
false |
corrupted_record |
StoreCorruptedRecordError |
false |
payload_hash_conflict |
StorePayloadHashConflictError |
false |
StoreError.message must never include secrets, signatures, authorization headers, or raw provider payloads.
isStoreLeaseLostError(error) is true for StoreLeaseLostError, name === "StoreLeaseLostError", or a plain StoreError with code: "lease_lost". Adapters must throw name === "StoreLeaseLostError". The webhook engine does not treat a bare domain throw with only that code as fencing, so handlers that reuse the code still reach store.fail.
Manifests
Every adapter that implements these contracts should export a StorageAdapterManifest. All fields are required. consistency.claims is always "strong" for a conforming manifest — only when claims use engine-level atomic ops.
import {
MEMORY_STORAGE_ADAPTER_MANIFEST,
assertStorageAdapterManifest,
isProductionSafeCoordination,
isStrongClaimAdapter,
} from "@paykernel/store-contracts";
assertStorageAdapterManifest(MEMORY_STORAGE_ADAPTER_MANIFEST);
isProductionSafeCoordination(MEMORY_STORAGE_ADAPTER_MANIFEST); // false
isStrongClaimAdapter(MEMORY_STORAGE_ADAPTER_MANIFEST); // false| Helper | Behavior (code) |
|---|---|
assertStorageAdapterManifest(m) |
Runtime shape/enum validation; throws TypeError |
isProductionSafeCoordination(m) |
false when coordinationScope === "single-process" or durability === "ephemeral" |
isStrongClaimAdapter(m) |
claims === "strong" and supportsLeases and isProductionSafeCoordination(m) |
coordinationScope values: single-process | single-host | multi-host | multi-region. No published adapter declares multi-region.
Adapter constants (import from the adapter package, not from this one):
| Constant | Package |
|---|---|
POSTGRES_STORAGE_ADAPTER_MANIFEST |
@paykernel/store-postgres |
REDIS_STORAGE_ADAPTER_MANIFEST |
@paykernel/store-redis |
SQLITE_STORAGE_ADAPTER_MANIFEST |
@paykernel/store-sqlite |
TURSO_STORAGE_ADAPTER_MANIFEST |
@paykernel/store-turso |
D1_STORAGE_ADAPTER_MANIFEST |
@paykernel/store-d1 |
DO_STORAGE_ADAPTER_MANIFEST |
@paykernel/store-durable-objects |
MEMORY_STORAGE_ADAPTER_MANIFEST |
this package (@paykernel/testkit re-exports) |
Memory (NON-PRODUCTION)
MEMORY_STORAGE_ADAPTER_MANIFEST: coordinationScope: "single-process", durability: "ephemeral". Factories (createMemoryStores, createMemoryIdempotencyStore, …) live in @paykernel/testkit, not here.
Restart loses all state. Do not put memory stores on a production payment path.
Dual ownership
| Interface | Also exported from | Why |
|---|---|---|
WebhookInboxStore |
@paykernel/webhooks |
Engine must not import testkit |
ReconciliationStore |
@paykernel/reconciliation |
Domain package must not import testkit |
Types are structurally compatible. Durable adapters must still pass run*StoreConformanceSuite from @paykernel/testkit.
Related
- How to pick an adapter: Adapter selection and Stores
- Shared SQL schemas:
@paykernel/sql-foundation - Inbox engine: Webhooks guide
- Outcomes: Outcomes