Skip to content

@paykernel/store-contracts

Portable lease-aware store interfaces, StoreError taxonomy, and storage adapter manifests.

Updated View as Markdown

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-contracts

Zero 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:

  • reserve returns kind: "indeterminate" and must not issue a new lease
  • Automatic replay is forbidden
  • deleteExpired must not remove indeterminate rows by default
  • Resolve with lookup + decideReconciliationPolicydo not createPayment again after outcome === "indeterminate" or reconciliationRequired

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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close