Adapter Interfaces
All adapter methods that perform fallible operations return Result<T>:
type Result<T, E = CommerceError> = | { ok: true; value: T; meta?: Record<string, unknown> } | { ok: false; error: E };Use Ok(value) for success and Err({ code, message }) for failure. Never throw from adapter methods. See Result Types for the rationale.
DatabaseAdapter
Section titled “DatabaseAdapter”interface DatabaseAdapter<TDatabase = unknown, TTransaction = unknown> { provider: string; db: TDatabase; transaction<T>(fn: (tx: TTransaction) => Promise<T>): Promise<T>;}| Member | Type | Description |
| ------------- | --------------------------------------------------------- | -------------------------------------------------- |
| provider | string | Database backend identifier (e.g., "postgresql") |
| db | TDatabase | The underlying Drizzle database instance |
| transaction | <T>(fn: (tx: TTransaction) => Promise<T>) => Promise<T> | Executes fn within a database transaction |
Implementations: @porulle/adapter-postgres (Drizzle on postgres-js) for long-lived Node servers, and @porulle/adapter-neon for serverless/Workers. neonAdapter({ connectionString, hyperdrive? }) uses the Neon HTTP driver for plain queries and opens a fresh WebSocket pool per transaction() (ended after each), sidestepping isolate-shared-pool flakiness; pass a Cloudflare hyperdrive binding to route the transaction pool through it.
PaymentAdapter
Section titled “PaymentAdapter”interface PaymentAdapter { readonly providerId: string; createPaymentIntent( params: CreatePaymentIntentParams, ): Promise<Result<PaymentIntent>>; capturePayment( paymentIntentId: string, amount?: number, ): Promise<Result<PaymentCapture>>; refundPayment( paymentId: string, amount: number, reason?: string, ): Promise<Result<PaymentRefund>>; cancelPaymentIntent(paymentIntentId: string): Promise<Result<void>>; verifyWebhook(request: Request): Promise<Result<PaymentWebhookEvent>>;}CreatePaymentIntentParams
Section titled “CreatePaymentIntentParams”| Field | Type | Required |
| ------------ | ------------------------ | -------- |
| amount | number | Yes |
| currency | string | Yes |
| orderId | string | Yes |
| customerId | string | No |
| metadata | Record<string, string> | No |
| terminalId | string | No |
PaymentIntent
Section titled “PaymentIntent”| Field | Type |
| -------------- | ---------------- |
| id | string |
| status | string |
| amount | number |
| currency | string |
| clientSecret | string \| null |
PaymentCapture
Section titled “PaymentCapture”| Field | Type |
| ---------------- | -------- |
| id | string |
| status | string |
| amountCaptured | number |
PaymentRefund
Section titled “PaymentRefund”| Field | Type |
| ---------------- | -------- |
| id | string |
| status | string |
| amountRefunded | number |
PaymentWebhookEvent
Section titled “PaymentWebhookEvent”| Field | Type |
| ------ | --------- |
| id | string |
| type | string |
| data | unknown |
For implementation guidance, see the Payment Adapter Contract and the Payment Adapter guide.
StorageAdapter
Section titled “StorageAdapter”interface StorageAdapter { readonly providerId: string; upload( key: string, data: ArrayBuffer | ReadableStream, contentType: string, ): Promise<Result<StoredFile>>; getUrl(key: string): Promise<Result<string>>; getSignedUrl(key: string, expiresIn: number): Promise<Result<string>>; delete(key: string): Promise<Result<void>>; list(prefix: string): Promise<Result<StoredFile[]>>;}StoredFile
Section titled “StoredFile”| Field | Type | Required |
| ------------- | -------- | -------- |
| key | string | Yes |
| url | string | Yes |
| contentType | string | Yes |
| size | number | No |
SearchAdapter
Section titled “SearchAdapter”interface SearchAdapter { readonly providerId: string; /** Optional. Called once when the search module wires the adapter. */ init?(deps: { db: PluginDb }): void; index(documents: SearchDocument[]): Promise<Result<void>>; remove(ids: string[]): Promise<Result<void>>; search(params: SearchQueryParams): Promise<Result<SearchQueryResult>>; suggest(params: SearchSuggestParams): Promise<Result<string[]>>;}init exists because an adapter is constructed in commerce.config.ts, before
a kernel exists — so an adapter needing the database cannot be handed one at
construction. The search module calls init when it wires the adapter, passing
the configured database. Implementing it is optional; @porulle/adapter-meilisearch
talks to a separate service and does not.
@porulle/adapter-pg-search uses it to default to the database porulle is
already connected to, so no second connection is needed:
import { pgSearchAdapter } from "@porulle/adapter-pg-search";
export default defineConfig({ search: { adapter: pgSearchAdapter() },});Pass query only when search should run against a different database. A
supplied query always wins — init never overwrites it.
SearchDocument
Section titled “SearchDocument”| Field | Type | Required |
| ------------- | ---------------------------------- | -------- |
| id | string | Yes |
| type | string | Yes |
| slug | string | Yes |
| title | string | Yes |
| description | string | No |
| status | string | No |
| categories | string[] | Yes |
| brands | string[] | Yes |
| text | string | Yes |
| attributes | Record<string, string \| string[]> | No |
| payload | Record<string, unknown> | No |
attributes holds the entity’s filterable custom-field values. Only fields whose definition sets filterable: true are indexed, and only approved en-locale values. A field with one value indexes as a string; multiple distinct values index as a string[].
SearchQueryParams
Section titled “SearchQueryParams”| Field | Type | Required |
| --------- | ---------------------------------------------------- | -------- |
| query | string | Yes |
| page | number | No |
| limit | number | No |
| filters | { type?, category?, brand?, status?, attributes? } | No |
| facets | string[] | No |
filters.attributes is a Record<string, string | string[]>: distinct names are ANDed, repeated values for one name are ORed, and matching is equality only. facets accepts attribute names alongside the built-in type, category, brand, status — the result counts each entity once per distinct value. Both shipped search adapters (@porulle/adapter-pg-search, @porulle/adapter-meilisearch) implement attribute filtering and attribute facets.
SearchQueryResult
Section titled “SearchQueryResult”| Field | Type |
| -------- | ---------------------------------------- |
| hits | SearchHit[] |
| total | number |
| page | number |
| limit | number |
| facets | Record<string, Record<string, number>> |
TaxAdapter
Section titled “TaxAdapter”interface TaxAdapter { readonly providerId: string; calculateTax( params: TaxCalculationParams, ): Promise<Result<TaxCalculationResult>>; reportTransaction( params: TaxReportParams, ): Promise<Result<{ transactionId: string }>>; voidTransaction( params: TaxVoidParams, ): Promise<Result<{ transactionId: string }>>;}TaxCalculationParams
Section titled “TaxCalculationParams”| Field | Type | Required |
| ---------------- | --------------- | -------- |
| currency | string | Yes |
| customerId | string | No |
| orderId | string | No |
| fromAddress | TaxAddress | No |
| toAddress | TaxAddress | No |
| shippingAmount | number | Yes |
| lineItems | TaxLineItem[] | Yes |
TaxCalculationResult
Section titled “TaxCalculationResult”| Field | Type |
| ----------------- | ------------------------- |
| amountToCollect | number |
| taxableAmount | number |
| rate | number |
| breakdown | Record<string, unknown> |
TaxAddress
Section titled “TaxAddress”| Field | Type | Required |
| ------------ | -------- | -------- |
| country | string | Yes |
| postalCode | string | Yes |
| state | string | No |
| city | string | No |
| line1 | string | No |
EmailAdapter
Section titled “EmailAdapter”interface EmailAdapter { send(input: { template: string; to: string; data?: Record<string, unknown>; }): Promise<void>;}Any object with a send method matching this signature works. See the Email Notifications guide for setup instructions.
Job execution engines
Section titled “Job execution engines”Task authors depend on the enqueue-only JobsAdapter surface exposed through hooks. Runtime configuration accepts the full ExecutionEngine, which registers the merged TaskDefinition map and declares how execution is driven:
interface ExecutionEngine extends JobsAdapter { readonly execution: | { mode: "pull"; run(options?: { queue?: string; limit?: number; }): Promise<RunJobsResult>; } | { mode: "push" }; register(setup: ExecutionEngineSetup): void;}The built-in DrizzleJobsAdapter is the default pull engine. It owns enqueue, claim, concurrency enforcement, retries, and stale-job recovery. A configured engine replaces it:
import { PgBossExecutionEngine } from "@porulle/jobs-pg-boss";
const jobs = new PgBossExecutionEngine({ connectionString: process.env.DATABASE_URL!,});
export default defineConfig({ jobs: { adapter: jobs, tasks: [importCatalogTask], },});
await jobs.start();| Package | Driver | Concurrency and scheduling | Use when |
| ----------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Built-in DrizzleJobsAdapter | Pull | Postgres claim transaction, exclusive keys, delayMs, configurable processingOrder | Default; no extra infrastructure |
| @porulle/jobs-pg-boss | Push worker | One singleton-policy queue preserves global key exclusivity; logical queue labels remain in job data. Includes retries, delayed jobs, and pending upsert. | You already operate Postgres and want pg-boss tooling |
| @porulle/jobs-inngest | Push | Inngest concurrency CEL key, retries, future event timestamps; supersession uses trailing debounce | Managed durable functions and Inngest observability |
| @porulle/jobs-trigger | Push | Trigger.dev queue concurrency plus concurrencyKey, retries, delayed triggers; supersession uses trailing debounce | Managed Trigger.dev task execution |
| @porulle/jobs-cloudflare | Push | Workflow step retries and sleeps. Exclusive/superseding keys require a durable CloudflareConcurrencyCoordinator. | A Workers-native deployment that can supply a Durable Object coordinator |
For Inngest, pass engine.functions to the framework’s serve() integration. Its retry and debounce policies are fixed when functions register, so put retries and supersedes on TaskDefinition rather than overriding them per enqueue. Trigger.dev indexes the task objects in engine.tasks. Cloudflare invokes engine.run(event.payload, step) from a WorkflowEntrypoint. These push engines must not use Porulle’s cron endpoint or jobs.autorun.
Available implementations
Section titled “Available implementations”| Package | Adapter | Interface |
| -------------------------------- | --------------------------- | ----------------- |
| @porulle/adapter-postgres | PostgreSQL (Drizzle) | DatabaseAdapter |
| @porulle/adapter-local-storage | Local filesystem | StorageAdapter |
| @porulle/adapter-s3 | AWS S3 | StorageAdapter |
| @porulle/adapter-r2 | Cloudflare R2 | StorageAdapter |
| @porulle/adapter-stripe | Stripe | PaymentAdapter |
| @porulle/adapter-resend | Resend | EmailAdapter |
| @porulle/adapter-ses | AWS SES v2 | EmailAdapter |
| consoleEmailAdapter() | Console (dev) | EmailAdapter |
| @porulle/adapter-meilisearch | Meilisearch | SearchAdapter |
| @porulle/adapter-pg-search | PostgreSQL full-text search | SearchAdapter |
| @porulle/adapter-taxjar | TaxJar | TaxAdapter |
| @porulle/adapter-tax-manual | Manual / flat-rate tax | TaxAdapter |
| @porulle/jobs-pg-boss | pg-boss | ExecutionEngine |
| @porulle/jobs-inngest | Inngest | ExecutionEngine |
| @porulle/jobs-trigger | Trigger.dev | ExecutionEngine |
| @porulle/jobs-cloudflare | Cloudflare Workflows | ExecutionEngine |