= {
+ memory: 'memory()',
postgres: 'postgres(process.env.PAYMESH_DATABASE_URL!)',
prisma: 'prisma(prismaClient)',
drizzle: 'drizzle(db)',
diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts
index dca3548..a0c5557 100644
--- a/packages/cli/test/cli.test.ts
+++ b/packages/cli/test/cli.test.ts
@@ -11,6 +11,7 @@ import {
resolveDatabaseSchema,
withRaw,
} from 'paymesh';
+import { memory } from '../../memory/src/index';
import {
createMigrationHistory,
createProgram,
@@ -1142,6 +1143,51 @@ describe('cli helpers', () => {
expect(statusQuery?.params).toContain(true);
});
+ test('getPaymeshStatus skips SQL aggregation for memory adapter', async () => {
+ const database = memory();
+ const client = {
+ provider: defineProvider({
+ id: 'stub',
+ isSandbox: () => false,
+ capabilities: {
+ checkout: true,
+ },
+ payments: {
+ create: async () => {
+ throw new Error('not used');
+ },
+ },
+ customers: {
+ get: async () => {
+ throw new Error('not used');
+ },
+ upsert: async () => {
+ throw new Error('not used');
+ },
+ delete: async () => {
+ throw new Error('not used');
+ },
+ },
+ }),
+ schema: resolveDatabaseSchema(),
+ database,
+ isSandbox: () => false,
+ };
+
+ const status = await getPaymeshStatus(client, [], [], {
+ exists: false,
+ valid: true,
+ missingFiles: [],
+ checksumMismatches: [],
+ migrations: [],
+ });
+
+ expect(status.database.connected).toBe(true);
+ expect(status.database.adapter).toBe('memory');
+ expect(status.database.ephemeral).toBe(true);
+ expect(status.catalog.productCount).toBeUndefined();
+ });
+
test('pushes catalog through cli helper', async () => {
const productWrites: Array<{ provider: string; count: number }> = [];
const priceWrites: Array<{ provider: string; count: number }> = [];
diff --git a/packages/memory/README.md b/packages/memory/README.md
new file mode 100644
index 0000000..6531791
--- /dev/null
+++ b/packages/memory/README.md
@@ -0,0 +1,70 @@
+
+
+@paymesh/memory
+
+
+ Ephemeral in-memory persistence for Paymesh.
+
+
+
+ Use Paymesh with a first-party database adapter in tests, CI, demos, and local development without provisioning Postgres or an ORM runtime.
+
+
+
+ Installation ·
+ Usage ·
+ What you get ·
+ When to use it
+
+
+
+
+Installation
+
+```bash
+npm install paymesh @paymesh/memory
+```
+
+Usage
+
+```ts
+import { createClient } from "paymesh";
+import { memory } from "@paymesh/memory";
+import { stripe } from "@paymesh/stripe";
+
+export const paymesh = createClient({
+ provider: stripe(),
+ database: memory({
+ seed: {
+ customers: [
+ {
+ id: "cus_seed",
+ provider: "stripe",
+ sandbox: true,
+ email: "ada@example.com",
+ },
+ ],
+ },
+ }),
+});
+
+const customer = await paymesh.customers.get("cus_seed");
+
+console.log(customer.email);
+```
+
+What You Get
+
+
+ @paymesh/memory implements the full Paymesh repository contract in memory, including transactions, webhook idempotency, catalog persistence, seeded startup data, and strict validation by default.
+
+
+When to Use It
+
+
+ Use @paymesh/memory for tests, CI, prototypes, docs examples, and local workflows where durable SQL storage would only add friction.
+
+
+
+ Do not treat it as a production database. It is process-local and ephemeral by design.
+
diff --git a/packages/memory/package.json b/packages/memory/package.json
new file mode 100644
index 0000000..bc799ea
--- /dev/null
+++ b/packages/memory/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@paymesh/memory",
+ "version": "0.0.0",
+ "description": "In-memory database adapter for Paymesh.",
+ "type": "module",
+ "license": "MIT",
+ "sideEffects": false,
+ "files": [
+ "dist"
+ ],
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "types": "./dist/index.d.cts",
+ "default": "./dist/index.cjs"
+ }
+ }
+ },
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.mts",
+ "scripts": {
+ "build": "tsdown",
+ "typecheck": "tsc --noEmit"
+ },
+ "peerDependencies": {
+ "paymesh": ">=0.0.0"
+ },
+ "devDependencies": {
+ "paymesh": "workspace:*"
+ }
+}
diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts
new file mode 100644
index 0000000..967f5ff
--- /dev/null
+++ b/packages/memory/src/index.ts
@@ -0,0 +1,52 @@
+import { type PaymeshDatabaseDriver, resolveDatabaseSchema } from 'paymesh';
+import { createMemoryDriver } from './shared/driver';
+import { applySeed, createEmptyState, type StateRef } from './state';
+import type { MemoryDatabaseOptions } from './types';
+
+export type * from './types';
+
+/**
+ * Creates a Paymesh database adapter backed by in-memory storage.
+ *
+ * Intended for tests, CI, demos, and local development. Supports transactions
+ * via state cloning and rollback, but rejects arbitrary SQL queries.
+ *
+ * @example
+ * ```ts
+ * const db = memory();
+ *
+ * const dbWithSeed = memory({
+ * strict: false,
+ * persistRaw: true,
+ * seed: {
+ * customers: [
+ * { id: 'cus_1', provider: 'stripe', sandbox: true, email: 'test@example.com' },
+ * ],
+ * },
+ * });
+ * ```
+ */
+export function memory(
+ options: MemoryDatabaseOptions = {},
+): PaymeshDatabaseDriver {
+ const { strict = true, persistRaw = false } = options;
+
+ const stateRef: StateRef = {
+ current: createEmptyState(),
+ };
+
+ applySeed(
+ stateRef.current,
+ resolveDatabaseSchema(),
+ options.seed,
+ strict,
+ persistRaw,
+ );
+
+ return createMemoryDriver({
+ strict,
+ stateRef,
+ persistRaw,
+ transactional: false,
+ });
+}
diff --git a/packages/memory/src/repositories.ts b/packages/memory/src/repositories.ts
new file mode 100644
index 0000000..0b86b9f
--- /dev/null
+++ b/packages/memory/src/repositories.ts
@@ -0,0 +1,597 @@
+import type {
+ BaseAnyPayment,
+ BasePix,
+ PaymeshDatabaseRepositories,
+ PaymeshRepositoryReadOptions,
+ ResolvedDatabaseSchema,
+} from 'paymesh';
+import { PaymeshError, withRaw } from 'paymesh';
+import { decodeCustomerCursor, encodeCustomerCursor } from './shared/cursor';
+import {
+ asRecord,
+ getPersistableCatalogRaw,
+ getPersistableRaw,
+ getVersion,
+ withoutRaw,
+} from './shared/raw';
+import {
+ applyTableFieldDefaults,
+ cloneValue,
+ stripTableFieldKeys,
+ validateRequiredTableFields,
+} from './shared/schema';
+import {
+ ensureCustomerExists,
+ ensureProductExists,
+ entityKey,
+ type MemoryDatabaseState,
+ validateRequiredBoolean,
+ validateRequiredNumber,
+ validateRequiredString,
+ validateSubscriptionReferences,
+} from './state';
+
+/** Internal options passed to repository implementations. */
+interface RepositoryOptions {
+ /** Returns the current in-memory database state. */
+ getState(): MemoryDatabaseState;
+ /** Whether to persist raw provider payloads. */
+ persistRaw: boolean;
+ /** Whether strict validation is enabled. */
+ strict: boolean;
+}
+
+/**
+ * Builds the full set of in-memory repository implementations.
+ *
+ * Returns a `PaymeshDatabaseRepositories` object with methods for customers,
+ * pix, checkouts, invoices, subscriptions, webhook events, products, prices,
+ * and migrations -- all backed by `Map`/`Set` structures.
+ */
+export function createRepositories(
+ options: RepositoryOptions,
+): PaymeshDatabaseRepositories {
+ return {
+ customers: {
+ async findByProviderId(_schema, provider, sandbox, id, readOptions) {
+ const customer = options
+ .getState()
+ .customers.get(entityKey(provider, sandbox, id));
+ if (!customer || customer.deletedAt) return null;
+
+ return mapStoredCustomer(customer, readOptions);
+ },
+ async findByEmail(_schema, provider, sandbox, email, readOptions) {
+ const customer = [...options.getState().customers.values()].find(
+ (row) =>
+ row.provider === provider &&
+ row.sandbox === sandbox &&
+ row.deletedAt === null &&
+ row.email === email,
+ );
+ if (!customer) return null;
+
+ return mapStoredCustomer(customer, readOptions);
+ },
+ async findByExternalId(
+ _schema,
+ provider,
+ sandbox,
+ externalId,
+ readOptions,
+ ) {
+ const customer = [...options.getState().customers.values()].find(
+ (row) =>
+ row.provider === provider &&
+ row.sandbox === sandbox &&
+ row.deletedAt === null &&
+ row.externalId === externalId,
+ );
+ if (!customer) return null;
+
+ return mapStoredCustomer(customer, readOptions);
+ },
+ async upsert(schema, customer) {
+ validateRequiredString(customer.id, 'id', schema.tables.customers.name);
+ validateRequiredString(
+ customer.provider,
+ 'provider',
+ schema.tables.customers.name,
+ );
+ validateRequiredBoolean(
+ customer.sandbox,
+ 'sandbox',
+ schema.tables.customers.name,
+ );
+
+ const prepared = applyTableFieldDefaults(
+ schema.tables.customers,
+ customer as Record,
+ );
+ validateRequiredTableFields(schema, 'customers', prepared);
+ const customerRecord = prepared as Record;
+
+ const key = entityKey(
+ customerRecord.provider as string,
+ customerRecord.sandbox as boolean,
+ customerRecord.id as string,
+ );
+ const existing = options.getState().customers.get(key);
+ const createdAt = existing?.createdAt ?? new Date().toISOString();
+ options.getState().customers.set(key, {
+ ...stripTableFieldKeys(
+ withoutRaw(customerRecord),
+ schema,
+ 'customers',
+ ),
+ ...customerRecord,
+ createdAt,
+ updatedAt: new Date().toISOString(),
+ deletedAt: null,
+ raw: getPersistableRaw(options.persistRaw, customer),
+ version: getVersion(customer, getPersistableRaw(true, customer)),
+ } as never);
+ },
+ async list(_schema, provider, sandbox, listOptions) {
+ const limit = listOptions?.limit ?? 20;
+ if (!Number.isInteger(limit) || limit <= 0) {
+ throw new PaymeshError({
+ code: 'invalid_request',
+ message: 'Customer list limit must be a positive integer',
+ });
+ }
+ if (listOptions?.after && listOptions?.before) {
+ throw new PaymeshError({
+ code: 'invalid_request',
+ message:
+ 'Customer list accepts either "after" or "before", not both',
+ });
+ }
+
+ const cursor = decodeCustomerCursor(
+ listOptions?.before ?? listOptions?.after,
+ listOptions?.before ? 'before' : 'after',
+ );
+ const filtered = [...options.getState().customers.values()]
+ .filter(
+ (customer) =>
+ customer.provider === provider &&
+ customer.sandbox === sandbox &&
+ customer.deletedAt === null,
+ )
+ .sort(compareCustomerRows);
+
+ let pageSource = filtered;
+ if (cursor?.mode === 'after') {
+ pageSource = filtered.filter(
+ (customer) =>
+ compareCustomerRowToCursor(customer, cursor.value) > 0,
+ );
+ } else if (cursor?.mode === 'before') {
+ pageSource = filtered
+ .filter(
+ (customer) =>
+ compareCustomerRowToCursor(customer, cursor.value) < 0,
+ )
+ .sort((left, right) => compareCustomerRows(right, left));
+ }
+
+ const hasExtra = pageSource.length > limit;
+ const windowRows = hasExtra ? pageSource.slice(0, limit) : pageSource;
+ const pageRows =
+ cursor?.mode === 'before' ? [...windowRows].reverse() : windowRows;
+ const data = pageRows.map((customer) =>
+ mapStoredCustomer(customer, listOptions),
+ );
+
+ return {
+ data,
+ total: filtered.length,
+ previous:
+ data.length === 0
+ ? null
+ : cursor?.mode === 'before'
+ ? hasExtra
+ ? encodeCustomerCursor({
+ createdAt: pageRows[0]!.createdAt,
+ providerId: pageRows[0]!.id,
+ })
+ : null
+ : cursor
+ ? encodeCustomerCursor({
+ createdAt: pageRows[0]!.createdAt,
+ providerId: pageRows[0]!.id,
+ })
+ : null,
+ next:
+ data.length === 0
+ ? null
+ : cursor?.mode === 'before'
+ ? encodeCustomerCursor({
+ createdAt: pageRows[pageRows.length - 1]!.createdAt,
+ providerId: pageRows[pageRows.length - 1]!.id,
+ })
+ : hasExtra
+ ? encodeCustomerCursor({
+ createdAt: pageRows[pageRows.length - 1]!.createdAt,
+ providerId: pageRows[pageRows.length - 1]!.id,
+ })
+ : null,
+ };
+ },
+ async markDeleted(_schema, customer) {
+ const key = entityKey(customer.provider, customer.sandbox, customer.id);
+ const existing = options.getState().customers.get(key);
+ options.getState().customers.set(key, {
+ id: customer.id,
+ provider: customer.provider,
+ sandbox: customer.sandbox,
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ deletedAt: new Date().toISOString(),
+ raw: getPersistableRaw(options.persistRaw, customer),
+ deleted: true,
+ version: getVersion(customer, getPersistableRaw(true, customer)),
+ });
+ },
+ },
+ pix: {
+ async findByProviderId(_schema, provider, sandbox, id, readOptions) {
+ const pix = options
+ .getState()
+ .pix.get(entityKey(provider, sandbox, id));
+ if (!pix) return null;
+
+ return mapStoredPayment(pix, readOptions) as never;
+ },
+ async upsert(schema, pix) {
+ validateAndStorePayment(schema, 'pix', pix, options);
+ },
+ },
+ checkouts: {
+ async findByProviderId(_schema, provider, sandbox, id) {
+ const payment = options
+ .getState()
+ .checkouts.get(entityKey(provider, sandbox, id));
+ if (!payment) return null;
+
+ return cloneValue(withoutRaw(payment)) as never;
+ },
+ async upsert(schema, payment) {
+ validateAndStorePayment(schema, 'checkouts', payment, options);
+ },
+ },
+ invoices: {
+ async findByProviderId(_schema, provider, sandbox, id) {
+ const payment = options
+ .getState()
+ .invoices.get(entityKey(provider, sandbox, id));
+ if (!payment) return null;
+
+ return cloneValue(withoutRaw(payment)) as never;
+ },
+ async upsert(schema, payment) {
+ validateAndStorePayment(schema, 'invoices', payment, options);
+ },
+ },
+ subscriptions: {
+ async findByProviderId(_schema, provider, sandbox, id) {
+ const subscription = options
+ .getState()
+ .subscriptions.get(entityKey(provider, sandbox, id));
+ if (!subscription) return null;
+
+ return cloneValue(asRecord(subscription.data));
+ },
+ async upsert(schema, event) {
+ validateRequiredString(
+ event.id,
+ 'id',
+ schema.tables.subscriptions.name,
+ );
+ validateRequiredString(
+ event.provider,
+ 'provider',
+ schema.tables.subscriptions.name,
+ );
+ validateRequiredBoolean(
+ event.sandbox,
+ 'sandbox',
+ schema.tables.subscriptions.name,
+ );
+ validateRequiredString(
+ event.type,
+ 'type',
+ schema.tables.subscriptions.name,
+ );
+
+ validateSubscriptionReferences(
+ options.getState(),
+ schema,
+ event,
+ options.strict,
+ );
+
+ const data = asRecord(event.data);
+ const providerId =
+ typeof data.id === 'string' && data.id.length > 0
+ ? data.id
+ : event.id;
+ const key = entityKey(event.provider, event.sandbox, providerId);
+ const existing = options.getState().subscriptions.get(key);
+ options.getState().subscriptions.set(key, {
+ id: providerId,
+ provider: event.provider,
+ sandbox: event.sandbox,
+ eventId: event.id,
+ type: event.type,
+ status:
+ event.type === 'subscription.canceled'
+ ? 'canceled'
+ : typeof data.status === 'string'
+ ? data.status
+ : 'active',
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ data: cloneValue(data),
+ raw: getPersistableRaw(options.persistRaw, event),
+ version: getVersion(event.data, getPersistableRaw(true, event.data)),
+ });
+ },
+ },
+ webhookEvents: {
+ async acquire(_schema, event, deliveryId) {
+ const key = entityKey(event.provider, event.sandbox, deliveryId);
+ const existing = options.getState().webhookEvents.get(key);
+ if (!existing) {
+ options.getState().webhookEvents.set(key, {
+ deliveryId,
+ provider: event.provider,
+ sandbox: event.sandbox,
+ type: event.type,
+ status: 'processing',
+ attempts: 1,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ processedAt: null,
+ lastError: null,
+ data: cloneValue(withoutRaw(event)),
+ raw: getPersistableRaw(options.persistRaw, event),
+ version: getVersion(event, getPersistableRaw(true, event)),
+ });
+ return { duplicate: false };
+ }
+
+ if (existing.status === 'failed') {
+ options.getState().webhookEvents.set(key, {
+ ...existing,
+ status: 'processing',
+ attempts: existing.attempts + 1,
+ updatedAt: new Date().toISOString(),
+ lastError: null,
+ data: cloneValue(withoutRaw(event)),
+ raw: getPersistableRaw(options.persistRaw, event),
+ });
+ return { duplicate: false };
+ }
+
+ return { duplicate: true };
+ },
+ async markProcessed(_schema, event, deliveryId) {
+ const key = entityKey(event.provider, event.sandbox, deliveryId);
+ const existing = options.getState().webhookEvents.get(key);
+ if (!existing) return;
+
+ options.getState().webhookEvents.set(key, {
+ ...existing,
+ status: 'processed',
+ processedAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ lastError: null,
+ });
+ },
+ async markFailed(_schema, event, deliveryId, error) {
+ const key = entityKey(event.provider, event.sandbox, deliveryId);
+ const existing = options.getState().webhookEvents.get(key);
+ if (!existing) return;
+
+ options.getState().webhookEvents.set(key, {
+ ...existing,
+ status: 'failed',
+ updatedAt: new Date().toISOString(),
+ lastError:
+ error instanceof Error ? error.message : 'Webhook handling failed',
+ });
+ },
+ },
+ products: {
+ async upsertMany(_schema, provider, products) {
+ for (const product of products) {
+ validateRequiredString(product.id, 'id', 'products');
+ validateRequiredBoolean(product.sandbox, 'sandbox', 'products');
+ const key = entityKey(provider, product.sandbox, product.id);
+ const existing = options.getState().products.get(key);
+ options.getState().products.set(key, {
+ ...cloneValue(product),
+ provider,
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ raw: getPersistableCatalogRaw(options.persistRaw, product.raw),
+ version: product.version ?? 'v1',
+ });
+ }
+ },
+ },
+ prices: {
+ async upsertMany(schema, provider, prices) {
+ for (const price of prices) {
+ validateRequiredString(price.id, 'id', 'prices');
+ validateRequiredBoolean(price.sandbox, 'sandbox', 'prices');
+ if (
+ typeof price.productId === 'string' &&
+ price.productId.length > 0
+ ) {
+ ensureProductExists(
+ options.getState(),
+ options.strict,
+ schema,
+ provider,
+ price.sandbox,
+ price.productId,
+ );
+ }
+ const key = entityKey(provider, price.sandbox, price.id);
+ const existing = options.getState().prices.get(key);
+ options.getState().prices.set(key, {
+ ...cloneValue(price),
+ provider,
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ raw: getPersistableCatalogRaw(options.persistRaw, price.raw),
+ version: price.version ?? 'v1',
+ });
+ }
+ },
+ },
+ migrations: {
+ async ensureTable() {},
+ async listApplied() {
+ return [...options.getState().migrations].sort();
+ },
+ async recordApplied(_schema, name) {
+ options.getState().migrations.add(name);
+ },
+ },
+ };
+}
+
+function validateAndStorePayment(
+ schema: ResolvedDatabaseSchema,
+ tableKey: 'pix' | 'checkouts' | 'invoices',
+ payment: BasePix | BaseAnyPayment,
+ options: RepositoryOptions,
+) {
+ validateRequiredString(payment.id, 'id', schema.tables[tableKey].name);
+ validateRequiredString(
+ payment.provider,
+ 'provider',
+ schema.tables[tableKey].name,
+ );
+ validateRequiredBoolean(
+ payment.sandbox,
+ 'sandbox',
+ schema.tables[tableKey].name,
+ );
+ validateRequiredNumber(
+ payment.amount,
+ 'amount',
+ schema.tables[tableKey].name,
+ );
+ validateRequiredString(
+ payment.currency,
+ 'currency',
+ schema.tables[tableKey].name,
+ );
+ validateRequiredString(
+ payment.status,
+ 'status',
+ schema.tables[tableKey].name,
+ );
+
+ if (payment.method === 'pix' && tableKey === 'pix') {
+ validateRequiredString(payment.method, 'method', schema.tables.pix.name);
+ }
+
+ const prepared = applyTableFieldDefaults(
+ schema.tables[tableKey],
+ payment as unknown as Record,
+ );
+ validateRequiredTableFields(schema, tableKey, prepared);
+ const paymentRecord = prepared as Record;
+ if (
+ paymentRecord.customer &&
+ typeof paymentRecord.customer === 'object' &&
+ 'id' in paymentRecord.customer
+ ) {
+ const customerId = (paymentRecord.customer as { id?: unknown }).id;
+ if (typeof customerId === 'string' && customerId.length > 0) {
+ ensureCustomerExists(
+ options.getState(),
+ options.strict,
+ schema,
+ paymentRecord.provider as string,
+ paymentRecord.sandbox as boolean,
+ customerId,
+ );
+ }
+ }
+
+ const key = entityKey(
+ paymentRecord.provider as string,
+ paymentRecord.sandbox as boolean,
+ paymentRecord.id as string,
+ );
+ const existing = options.getState()[tableKey].get(key);
+ options.getState()[tableKey].set(key, {
+ ...stripTableFieldKeys(withoutRaw(paymentRecord), schema, tableKey),
+ ...paymentRecord,
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ raw: getPersistableRaw(options.persistRaw, payment),
+ version: getVersion(payment, getPersistableRaw(true, payment)),
+ } as never);
+}
+
+function mapStoredCustomer(
+ customer: Record & {
+ id: string;
+ provider: string;
+ sandbox: boolean;
+ raw: unknown;
+ },
+ readOptions?: PaymeshRepositoryReadOptions,
+) {
+ const {
+ raw,
+ createdAt: _createdAt,
+ updatedAt: _updatedAt,
+ deletedAt: _deletedAt,
+ ...data
+ } = customer;
+ return withRaw(cloneValue(data), raw, readOptions?.includeRaw) as never;
+}
+
+function mapStoredPayment(
+ payment: Record & {
+ raw: unknown;
+ },
+ readOptions?: PaymeshRepositoryReadOptions,
+) {
+ const {
+ raw,
+ createdAt: _createdAt,
+ updatedAt: _updatedAt,
+ ...data
+ } = payment;
+ return withRaw(cloneValue(data), raw, readOptions?.includeRaw) as never;
+}
+
+function compareCustomerRows(
+ left: { createdAt: string; id: string },
+ right: { createdAt: string; id: string },
+) {
+ const createdDiff = left.createdAt.localeCompare(right.createdAt);
+ if (createdDiff !== 0) return createdDiff;
+
+ return left.id.localeCompare(right.id);
+}
+
+function compareCustomerRowToCursor(
+ row: { createdAt: string; id: string },
+ cursor: { createdAt: string; providerId: string },
+) {
+ const createdDiff = row.createdAt.localeCompare(cursor.createdAt);
+ if (createdDiff !== 0) return createdDiff;
+
+ return row.id.localeCompare(cursor.providerId);
+}
diff --git a/packages/memory/src/shared/cursor.ts b/packages/memory/src/shared/cursor.ts
new file mode 100644
index 0000000..9d245e1
--- /dev/null
+++ b/packages/memory/src/shared/cursor.ts
@@ -0,0 +1,79 @@
+import { PaymeshError } from 'paymesh';
+
+/**
+ * Decoded customer list pagination cursor.
+ *
+ * Contains the cursor mode (`after` or `before`) and the positional value
+ * used for keyset pagination.
+ */
+export interface CustomerListCursor {
+ /** Whether this cursor is for forward (`after`) or backward (`before`) pagination. */
+ mode: 'after' | 'before';
+ /** Positional value containing the `createdAt` timestamp and `providerId` of the boundary record. */
+ value: {
+ /** ISO-8601 creation timestamp of the boundary record. */
+ createdAt: string;
+ /** Provider-assigned customer id of the boundary record. */
+ providerId: string;
+ };
+}
+
+/**
+ * Encodes a cursor value into a base64url string prefixed with `pc1.`.
+ *
+ * The cursor is an opaque token intended to be passed back by the client
+ * for keyset pagination.
+ */
+export function encodeCustomerCursor(row: {
+ createdAt: string;
+ providerId: string;
+}) {
+ return `pc1.${Buffer.from(JSON.stringify(row)).toString('base64url')}`;
+}
+
+/**
+ * Decodes a base64url cursor string back into a {@link CustomerListCursor}.
+ *
+ * Returns `null` when the cursor value is `undefined`. Throws a
+ * `PaymeshError` with `invalid_request` code on malformed input.
+ */
+export function decodeCustomerCursor(
+ cursorValue: string | undefined,
+ mode: 'after' | 'before',
+): CustomerListCursor | null {
+ if (!cursorValue) return null;
+
+ if (!cursorValue.startsWith('pc1.'))
+ throw new PaymeshError({
+ code: 'invalid_request',
+ message: 'Invalid customer list cursor',
+ });
+
+ try {
+ const parsed = JSON.parse(
+ Buffer.from(cursorValue.slice(4), 'base64url').toString('utf8'),
+ ) as Record;
+
+ if (
+ typeof parsed.createdAt !== 'string' ||
+ typeof parsed.providerId !== 'string' ||
+ parsed.createdAt.length === 0 ||
+ parsed.providerId.length === 0
+ )
+ throw new Error('invalid');
+
+ return {
+ mode,
+ value: {
+ createdAt: parsed.createdAt,
+ providerId: parsed.providerId,
+ },
+ };
+ } catch (err) {
+ throw new PaymeshError({
+ cause: err,
+ code: 'invalid_request',
+ message: 'Invalid customer list cursor',
+ });
+ }
+}
diff --git a/packages/memory/src/shared/driver.ts b/packages/memory/src/shared/driver.ts
new file mode 100644
index 0000000..1a5f39b
--- /dev/null
+++ b/packages/memory/src/shared/driver.ts
@@ -0,0 +1,72 @@
+import {
+ defineDatabaseAdapter,
+ type PaymeshDatabaseDriver,
+ PaymeshError,
+} from 'paymesh';
+import { createRepositories } from '../repositories';
+import { cloneState, type StateRef } from '../state';
+
+export function createMemoryDriver({
+ strict,
+ stateRef,
+ persistRaw,
+ transactional,
+}: {
+ strict: boolean;
+ stateRef: StateRef;
+ persistRaw: boolean;
+ transactional: boolean;
+}): PaymeshDatabaseDriver {
+ const getState = () => stateRef.current;
+
+ const driver = defineDatabaseAdapter({
+ id: 'memory',
+ dialect: 'postgres',
+ persistRaw,
+ repositories: createRepositories({
+ getState,
+ persistRaw,
+ strict,
+ }),
+ async query() {
+ throw new PaymeshError({
+ code: 'database_error',
+ message:
+ '@paymesh/memory does not execute arbitrary SQL. Use repository methods instead.',
+ });
+ },
+ async execute() {
+ throw new PaymeshError({
+ code: 'database_error',
+ message:
+ '@paymesh/memory does not execute arbitrary SQL. Use repository methods instead.',
+ });
+ },
+ async transaction(
+ callback: (database: PaymeshDatabaseDriver) => Promise,
+ ) {
+ if (transactional) {
+ return callback(driver);
+ }
+
+ const nextStateRef: StateRef = {
+ current: cloneState(stateRef.current),
+ };
+
+ const tx = createMemoryDriver({
+ strict,
+ persistRaw,
+ transactional: true,
+ stateRef: nextStateRef,
+ });
+
+ const result = await callback(tx);
+
+ stateRef.current = nextStateRef.current;
+
+ return result;
+ },
+ });
+
+ return driver;
+}
diff --git a/packages/memory/src/shared/raw.ts b/packages/memory/src/shared/raw.ts
new file mode 100644
index 0000000..09d8d4b
--- /dev/null
+++ b/packages/memory/src/shared/raw.ts
@@ -0,0 +1,90 @@
+/**
+ * Drops the public `raw` field from a payload before storing normalized data.
+ *
+ * @example
+ * ```ts
+ * const data = withoutRaw({ id: '1', name: 'test', raw: { ... } });
+ * // => { id: '1', name: 'test' }
+ * ```
+ */
+export function withoutRaw(value: unknown) {
+ if (typeof value !== 'object' || value === null) return {};
+
+ const { raw: _raw, ...data } = value as Record;
+
+ return data;
+}
+
+/**
+ * Converts unknown values into records when possible.
+ *
+ * Returns an empty object for non-object values.
+ *
+ * @example
+ * ```ts
+ * asRecord({ foo: 1 }); // => { foo: 1 }
+ * asRecord(null); // => {}
+ * asRecord('hello'); // => {}
+ * ```
+ */
+export function asRecord(value: unknown) {
+ return typeof value === 'object' && value !== null
+ ? (value as Record)
+ : {};
+}
+
+/**
+ * Reads the hidden raw payload attached through `withRaw`.
+ *
+ * The raw payload is stored under the `Symbol.for('paymesh.raw')` key.
+ * Returns `null` when no raw payload is present.
+ */
+export function getInternalRaw(value: unknown) {
+ if (typeof value !== 'object' || value === null) return null;
+
+ const rawKey = Symbol.for('paymesh.raw');
+ return (value as Record)[rawKey] ?? null;
+}
+
+/**
+ * Returns the raw payload to persist for normalized provider records.
+ *
+ * Extracts the internal raw via `Symbol.for('paymesh.raw')` when `persistRaw`
+ * is `true`, otherwise returns `null`.
+ */
+export function getPersistableRaw(persistRaw: boolean, value: unknown) {
+ return persistRaw ? (getInternalRaw(value) ?? null) : null;
+}
+
+/**
+ * Returns the raw payload to persist for catalog snapshots.
+ *
+ * Catalog records store the raw value directly (unlike normalized records
+ * which use `Symbol.for('paymesh.raw')`). Returns `null` when `persistRaw`
+ * is `false`.
+ */
+export function getPersistableCatalogRaw(persistRaw: boolean, value: unknown) {
+ return persistRaw ? (value ?? null) : null;
+}
+
+/**
+ * Resolves a stable version label from a record or its metadata.
+ *
+ * Checks for a `version` string on the record or its `metadata.version`,
+ * falling back to `'v1'` when no version is found.
+ */
+export function getVersion(value: unknown, raw: unknown) {
+ for (const candidate of [value, raw]) {
+ const record = asRecord(candidate);
+
+ if (typeof record.version === 'string' && record.version.length > 0)
+ return record.version;
+
+ const metadata = asRecord(record.metadata);
+
+ if (typeof metadata.version === 'string' && metadata.version.length > 0)
+ return metadata.version;
+ }
+
+ return 'v1';
+}
diff --git a/packages/memory/src/shared/schema.ts b/packages/memory/src/shared/schema.ts
new file mode 100644
index 0000000..47a0d3c
--- /dev/null
+++ b/packages/memory/src/shared/schema.ts
@@ -0,0 +1,109 @@
+import type {
+ DatabaseTableKey,
+ ResolvedDatabaseSchema,
+ ResolvedDatabaseTable,
+} from 'paymesh';
+import { PaymeshError } from 'paymesh';
+
+/**
+ * Applies default values from a resolved table schema to a record.
+ *
+ * Fields that are `undefined` in the record but have a `default` defined
+ * in the schema are filled with a deep-cloned copy of the default value.
+ */
+export function applyTableFieldDefaults<
+ TRecord extends Record,
+>(table: ResolvedDatabaseTable, record: TRecord) {
+ const next: Record = { ...record };
+
+ for (const field of Object.values(table.fields)) {
+ if (next[field.key] === undefined && field.default !== undefined) {
+ next[field.key] = cloneValue(field.default);
+ }
+ }
+
+ return next;
+}
+
+/**
+ * Validates that all required fields (per schema) are present and non-null
+ * in a record.
+ *
+ * Throws a `PaymeshError` with `database_error` code when a required field
+ * without a default value is missing.
+ */
+export function validateRequiredTableFields(
+ schema: ResolvedDatabaseSchema,
+ tableKey: DatabaseTableKey,
+ record: Record,
+) {
+ const table = schema.tables[tableKey];
+
+ for (const field of Object.values(table.fields)) {
+ if (
+ field.required &&
+ field.default === undefined &&
+ (record[field.key] === undefined || record[field.key] === null)
+ ) {
+ throw new PaymeshError({
+ code: 'database_error',
+ message: `Missing required field "${field.key}" for table "${table.name}".`,
+ });
+ }
+ }
+}
+
+/**
+ * Removes all keys that match defined table field keys from a record.
+ *
+ * Returns a new object containing only the extra (non-schema) keys, which
+ * are used as additional metadata attached to stored records.
+ */
+export function stripTableFieldKeys(
+ record: Record,
+ schema: ResolvedDatabaseSchema,
+ tableKey: DatabaseTableKey,
+) {
+ const next = { ...record };
+
+ for (const key of Object.keys(schema.tables[tableKey].fields)) {
+ delete next[key];
+ }
+
+ return next;
+}
+
+/**
+ * Copies defined table field values from a record onto a target object.
+ *
+ * Ensures that all schema-defined keys are present in the returned object,
+ * preserving any existing values from the input record.
+ */
+export function hydrateTableFieldKeys>(
+ record: TRecord,
+ table: ResolvedDatabaseTable,
+) {
+ const next: Record = { ...record };
+
+ for (const field of Object.values(table.fields)) {
+ if (record[field.key] !== undefined && record[field.key] !== null) {
+ next[field.key] = record[field.key];
+ }
+ }
+
+ return next;
+}
+
+/**
+ * Deep-clones a value using `structuredClone`.
+ *
+ * Falls back to returning the original value if cloning fails (e.g. for
+ * functions, symbols, or circular references).
+ */
+export function cloneValue(value: T): T {
+ try {
+ return structuredClone(value);
+ } catch {
+ return value;
+ }
+}
diff --git a/packages/memory/src/state.ts b/packages/memory/src/state.ts
new file mode 100644
index 0000000..0d32ff7
--- /dev/null
+++ b/packages/memory/src/state.ts
@@ -0,0 +1,763 @@
+import type { BasePaymeshEvent, ResolvedDatabaseSchema } from 'paymesh';
+import { PaymeshError } from 'paymesh';
+import { getInternalRaw } from './shared/raw';
+import {
+ applyTableFieldDefaults,
+ cloneValue,
+ stripTableFieldKeys,
+ validateRequiredTableFields,
+} from './shared/schema';
+import type {
+ MemoryDatabaseSeed,
+ MemorySeedCustomer,
+ MemorySeedPayment,
+ MemorySeedPix,
+ MemorySeedPrice,
+ MemorySeedProduct,
+ MemorySeedSubscription,
+ MemorySeedWebhookEvent,
+} from './types';
+
+/** Mutable reference wrapper that enables transactional state swapping. */
+export interface StateRef {
+ /** The current in-memory database state. */
+ current: MemoryDatabaseState;
+}
+
+/** Shape of a customer record as stored in the in-memory database. */
+export interface StoredCustomer extends Record {
+ /** Unique customer identifier assigned by the provider. */
+ id: string;
+ /** Provider identifier that owns this customer. */
+ provider: string;
+ /** Whether this record belongs to the sandbox environment. */
+ sandbox: boolean;
+ /** ISO-8601 creation timestamp. */
+ createdAt: string;
+ /** ISO-8601 last update timestamp. */
+ updatedAt: string;
+ /** ISO-8601 soft-deletion timestamp, or `null` if active. */
+ deletedAt: string | null;
+ /** Persisted raw provider payload, or `null` when `persistRaw` is disabled. */
+ raw: unknown;
+}
+
+/** Shape of a payment record (pix, checkout, or invoice) as stored in memory. */
+export interface StoredPayment extends Record {
+ /** Unique payment identifier assigned by the provider. */
+ id: string;
+ /** Provider identifier that owns this payment. */
+ provider: string;
+ /** Whether this record belongs to the sandbox environment. */
+ sandbox: boolean;
+ /** Payment amount in the smallest currency unit. */
+ amount: number;
+ /** ISO-4217 currency code. */
+ currency: string;
+ /** Normalized payment status. */
+ status: string;
+ /** ISO-8601 creation timestamp. */
+ createdAt: string;
+ /** ISO-8601 last update timestamp. */
+ updatedAt: string;
+ /** Persisted raw provider payload, or `null` when `persistRaw` is disabled. */
+ raw: unknown;
+}
+
+/** Shape of a subscription event record as stored in memory. */
+export interface StoredSubscription extends Record {
+ /** Provider-assigned subscription identifier. */
+ id: string;
+ /** Provider identifier that owns this subscription. */
+ provider: string;
+ /** Whether this record belongs to the sandbox environment. */
+ sandbox: boolean;
+ /** Unique event identifier from the provider. */
+ eventId: string;
+ /** Event type (e.g. `subscription.created`, `subscription.canceled`). */
+ type: string;
+ /** Normalized subscription status. */
+ status: string;
+ /** ISO-8601 creation timestamp. */
+ createdAt: string;
+ /** ISO-8601 last update timestamp. */
+ updatedAt: string;
+ /** Persisted raw provider payload, or `null` when `persistRaw` is disabled. */
+ raw: unknown;
+}
+
+/** Shape of a webhook delivery event record as stored in memory. */
+export interface StoredWebhookEvent extends Record {
+ /** Unique delivery identifier used as the idempotency key. */
+ deliveryId: string;
+ /** Provider identifier that owns this event. */
+ provider: string;
+ /** Whether this record belongs to the sandbox environment. */
+ sandbox: boolean;
+ /** Webhook event type (e.g. `payment.paid`). */
+ type: string;
+ /** Current processing status of the delivery. */
+ status: 'processing' | 'processed' | 'failed';
+ /** Number of delivery attempts made so far. */
+ attempts: number;
+ /** ISO-8601 creation timestamp. */
+ createdAt: string;
+ /** ISO-8601 last update timestamp. */
+ updatedAt: string;
+ /** ISO-8601 timestamp of successful processing, or `null` if not yet processed. */
+ processedAt: string | null;
+ /** Error message from the last failed attempt, or `null` if no error. */
+ lastError: string | null;
+ /** Persisted raw provider payload, or `null` when `persistRaw` is disabled. */
+ raw: unknown;
+}
+
+/** Shape of a catalog record (product or price) as stored in memory. */
+export interface StoredCatalogRecord extends Record {
+ /** Unique catalog record identifier assigned by the provider. */
+ id: string;
+ /** Provider identifier that owns this catalog record. */
+ provider: string;
+ /** Whether this record belongs to the sandbox environment. */
+ sandbox: boolean;
+ /** ISO-8601 creation timestamp. */
+ createdAt: string;
+ /** ISO-8601 last update timestamp. */
+ updatedAt: string;
+ /** Persisted raw provider payload, or `null` when `persistRaw` is disabled. */
+ raw: unknown;
+}
+
+/**
+ * Complete in-memory database state.
+ *
+ * Each entity collection is stored in a `Map` keyed by a composite
+ * `"provider:sandbox:id"` string. Migrations are tracked in a `Set`.
+ */
+export interface MemoryDatabaseState {
+ /** Customer records indexed by composite entity key. */
+ customers: Map;
+ /** Pix payment records indexed by composite entity key. */
+ pix: Map;
+ /** Checkout payment records indexed by composite entity key. */
+ checkouts: Map;
+ /** Invoice payment records indexed by composite entity key. */
+ invoices: Map;
+ /** Subscription event records indexed by composite entity key. */
+ subscriptions: Map;
+ /** Webhook delivery event records indexed by composite entity key. */
+ webhookEvents: Map;
+ /** Catalog product records indexed by composite entity key. */
+ products: Map;
+ /** Catalog price records indexed by composite entity key. */
+ prices: Map;
+ /** Set of applied migration names. */
+ migrations: Set;
+}
+
+/** Creates a fresh, empty in-memory database state with all collections cleared. */
+export function createEmptyState(): MemoryDatabaseState {
+ return {
+ customers: new Map(),
+ pix: new Map(),
+ checkouts: new Map(),
+ invoices: new Map(),
+ subscriptions: new Map(),
+ webhookEvents: new Map(),
+ products: new Map(),
+ prices: new Map(),
+ migrations: new Set(),
+ };
+}
+
+/**
+ * Deep-clones an existing database state for transactional isolation.
+ *
+ * Each map entry is cloned via `structuredClone` so mutations inside a
+ * transaction do not affect the original state.
+ */
+export function cloneState(state: MemoryDatabaseState): MemoryDatabaseState {
+ return {
+ customers: cloneMap(state.customers),
+ pix: cloneMap(state.pix),
+ checkouts: cloneMap(state.checkouts),
+ invoices: cloneMap(state.invoices),
+ subscriptions: cloneMap(state.subscriptions),
+ webhookEvents: cloneMap(state.webhookEvents),
+ products: cloneMap(state.products),
+ prices: cloneMap(state.prices),
+ migrations: new Set(state.migrations),
+ };
+}
+
+function cloneMap(map: Map) {
+ return new Map(
+ [...map.entries()].map(([key, value]) => [key, cloneValue(value)]),
+ );
+}
+
+/**
+ * Builds a composite string key from provider, sandbox flag, and entity id.
+ *
+ * The key format is `"provider:sandbox:id"` and is used as the Map key
+ * for all in-memory entity lookups.
+ */
+export function entityKey(provider: string, sandbox: boolean, id: string) {
+ return `${provider}:${String(sandbox)}:${id}`;
+}
+
+/**
+ * Throws a `PaymeshError` if the provided value is not a non-empty string.
+ */
+export function validateRequiredString(
+ value: unknown,
+ label: string,
+ tableName: string,
+) {
+ if (typeof value !== 'string' || value.length === 0) {
+ throw new PaymeshError({
+ code: 'database_error',
+ message: `Missing required field "${label}" for table "${tableName}".`,
+ });
+ }
+}
+
+/**
+ * Throws a `PaymeshError` if the provided value is not a boolean.
+ */
+export function validateRequiredBoolean(
+ value: unknown,
+ label: string,
+ tableName: string,
+) {
+ if (typeof value !== 'boolean') {
+ throw new PaymeshError({
+ code: 'database_error',
+ message: `Missing required field "${label}" for table "${tableName}".`,
+ });
+ }
+}
+
+/**
+ * Throws a `PaymeshError` if the provided value is not a non-NaN number.
+ */
+export function validateRequiredNumber(
+ value: unknown,
+ label: string,
+ tableName: string,
+) {
+ if (typeof value !== 'number' || Number.isNaN(value)) {
+ throw new PaymeshError({
+ code: 'database_error',
+ message: `Missing required field "${label}" for table "${tableName}".`,
+ });
+ }
+}
+
+/**
+ * Throws a `PaymeshError` on duplicate insert when strict mode is enabled
+ * and the record already exists.
+ */
+export function validateUniqueInsert(
+ strict: boolean,
+ exists: boolean,
+ tableName: string,
+ id: string,
+) {
+ if (!strict || !exists) return;
+
+ throw new PaymeshError({
+ code: 'database_error',
+ message: `Duplicate unique id "${id}" for table "${tableName}".`,
+ });
+}
+
+/**
+ * In strict mode, throws a `PaymeshError` if the referenced customer
+ * does not exist or has been soft-deleted.
+ */
+export function ensureCustomerExists(
+ state: MemoryDatabaseState,
+ strict: boolean,
+ schema: ResolvedDatabaseSchema,
+ provider: string,
+ sandbox: boolean,
+ id: string,
+) {
+ if (!strict) return;
+
+ const customer = state.customers.get(entityKey(provider, sandbox, id));
+ if (!customer || customer.deletedAt) {
+ throw new PaymeshError({
+ code: 'database_error',
+ message: `Related customer "${id}" does not exist in table "${schema.tables.customers.name}".`,
+ });
+ }
+}
+
+/**
+ * In strict mode, throws a `PaymeshError` if the referenced product
+ * does not exist in the in-memory catalog.
+ */
+export function ensureProductExists(
+ state: MemoryDatabaseState,
+ strict: boolean,
+ schema: ResolvedDatabaseSchema,
+ provider: string,
+ sandbox: boolean,
+ id: string,
+) {
+ if (!strict) return;
+
+ if (!state.products.has(entityKey(provider, sandbox, id))) {
+ throw new PaymeshError({
+ code: 'database_error',
+ message: `Related product "${id}" does not exist in table "${schema.tables.products.name}".`,
+ });
+ }
+}
+
+/**
+ * In strict mode, throws a `PaymeshError` if the referenced price
+ * does not exist in the in-memory catalog.
+ */
+export function ensurePriceExists(
+ state: MemoryDatabaseState,
+ strict: boolean,
+ schema: ResolvedDatabaseSchema,
+ provider: string,
+ sandbox: boolean,
+ id: string,
+) {
+ if (!strict) return;
+
+ if (!state.prices.has(entityKey(provider, sandbox, id))) {
+ throw new PaymeshError({
+ code: 'database_error',
+ message: `Related price "${id}" does not exist in table "${schema.tables.prices.name}".`,
+ });
+ }
+}
+
+/**
+ * Populates a database state from a seed configuration object.
+ *
+ * Inserts all entity types (customers, products, prices, pix, checkouts,
+ * invoices, subscriptions, webhook events, migrations) with full validation.
+ * Skipped silently when `seed` is `undefined`.
+ */
+export function applySeed(
+ state: MemoryDatabaseState,
+ schema: ResolvedDatabaseSchema,
+ seed: MemoryDatabaseSeed | undefined,
+ strict: boolean,
+ persistRaw: boolean,
+) {
+ if (!seed) return;
+
+ for (const customer of seed.customers ?? []) {
+ insertSeedCustomer(state, schema, customer, strict, persistRaw);
+ }
+
+ for (const product of seed.products ?? []) {
+ insertSeedProduct(state, schema, product, strict, persistRaw);
+ }
+
+ for (const price of seed.prices ?? []) {
+ insertSeedPrice(state, schema, price, strict, persistRaw);
+ }
+
+ for (const pix of seed.pix ?? []) {
+ insertSeedPayment(state, schema, 'pix', pix, strict, persistRaw);
+ }
+
+ for (const checkout of seed.checkouts ?? []) {
+ insertSeedPayment(state, schema, 'checkouts', checkout, strict, persistRaw);
+ }
+
+ for (const invoice of seed.invoices ?? []) {
+ insertSeedPayment(state, schema, 'invoices', invoice, strict, persistRaw);
+ }
+
+ for (const subscription of seed.subscriptions ?? []) {
+ insertSeedSubscription(state, schema, subscription, strict, persistRaw);
+ }
+
+ for (const event of seed.webhookEvents ?? []) {
+ insertSeedWebhookEvent(state, event, strict, persistRaw);
+ }
+
+ for (const name of seed.migrations ?? []) {
+ validateRequiredString(name, 'name', schema.tables.migrations.name);
+ validateUniqueInsert(
+ strict,
+ state.migrations.has(name),
+ schema.tables.migrations.name,
+ name,
+ );
+ state.migrations.add(name);
+ }
+}
+
+function insertSeedCustomer(
+ state: MemoryDatabaseState,
+ schema: ResolvedDatabaseSchema,
+ customer: MemorySeedCustomer,
+ strict: boolean,
+ persistRaw: boolean,
+) {
+ validateRequiredString(customer.id, 'id', schema.tables.customers.name);
+ validateRequiredString(
+ customer.provider,
+ 'provider',
+ schema.tables.customers.name,
+ );
+ validateRequiredBoolean(
+ customer.sandbox,
+ 'sandbox',
+ schema.tables.customers.name,
+ );
+
+ const next = applyTableFieldDefaults(
+ schema.tables.customers,
+ customer as unknown as Record,
+ );
+ validateRequiredTableFields(schema, 'customers', next);
+ const customerRecord = next as Record;
+
+ const key = entityKey(
+ customerRecord.provider as string,
+ customerRecord.sandbox as boolean,
+ customerRecord.id as string,
+ );
+ validateUniqueInsert(
+ strict,
+ state.customers.has(key),
+ schema.tables.customers.name,
+ customerRecord.id as string,
+ );
+ const now =
+ typeof customerRecord.createdAt === 'string'
+ ? customerRecord.createdAt
+ : new Date().toISOString();
+ state.customers.set(key, {
+ ...stripTableFieldKeys(customerRecord, schema, 'customers'),
+ ...customerRecord,
+ createdAt: now,
+ updatedAt:
+ typeof customerRecord.updatedAt === 'string'
+ ? customerRecord.updatedAt
+ : now,
+ deletedAt: customerRecord.deleted ? now : null,
+ raw: persistRaw
+ ? (customerRecord.raw ?? getInternalRaw(customerRecord) ?? null)
+ : null,
+ } as never);
+}
+
+function insertSeedPayment(
+ state: MemoryDatabaseState,
+ schema: ResolvedDatabaseSchema,
+ tableKey: 'pix' | 'checkouts' | 'invoices',
+ payment: MemorySeedPix | MemorySeedPayment,
+ strict: boolean,
+ persistRaw: boolean,
+) {
+ validateRequiredString(payment.id, 'id', schema.tables[tableKey].name);
+ validateRequiredString(
+ payment.provider,
+ 'provider',
+ schema.tables[tableKey].name,
+ );
+ validateRequiredBoolean(
+ payment.sandbox,
+ 'sandbox',
+ schema.tables[tableKey].name,
+ );
+ validateRequiredNumber(
+ payment.amount,
+ 'amount',
+ schema.tables[tableKey].name,
+ );
+ validateRequiredString(
+ payment.currency,
+ 'currency',
+ schema.tables[tableKey].name,
+ );
+ validateRequiredString(
+ payment.status,
+ 'status',
+ schema.tables[tableKey].name,
+ );
+
+ const next = applyTableFieldDefaults(
+ schema.tables[tableKey],
+ payment as unknown as Record,
+ );
+ validateRequiredTableFields(schema, tableKey, next);
+ const paymentRecord = next as Record;
+
+ if (
+ paymentRecord.customer &&
+ typeof paymentRecord.customer === 'object' &&
+ 'id' in paymentRecord.customer
+ ) {
+ const customerId = (paymentRecord.customer as { id?: unknown }).id;
+ if (typeof customerId === 'string' && customerId.length > 0) {
+ ensureCustomerExists(
+ state,
+ strict,
+ schema,
+ paymentRecord.provider as string,
+ paymentRecord.sandbox as boolean,
+ customerId,
+ );
+ }
+ }
+
+ const key = entityKey(
+ paymentRecord.provider as string,
+ paymentRecord.sandbox as boolean,
+ paymentRecord.id as string,
+ );
+ validateUniqueInsert(
+ strict,
+ state[tableKey].has(key),
+ schema.tables[tableKey].name,
+ paymentRecord.id as string,
+ );
+
+ const now =
+ typeof paymentRecord.createdAt === 'string'
+ ? paymentRecord.createdAt
+ : new Date().toISOString();
+ state[tableKey].set(key, {
+ ...stripTableFieldKeys(paymentRecord, schema, tableKey),
+ ...paymentRecord,
+ createdAt: now,
+ updatedAt:
+ typeof paymentRecord.updatedAt === 'string'
+ ? paymentRecord.updatedAt
+ : now,
+ raw: persistRaw
+ ? (paymentRecord.raw ?? getInternalRaw(paymentRecord) ?? null)
+ : null,
+ } as never);
+}
+
+function insertSeedSubscription(
+ state: MemoryDatabaseState,
+ schema: ResolvedDatabaseSchema,
+ event: MemorySeedSubscription,
+ strict: boolean,
+ persistRaw: boolean,
+) {
+ validateSubscriptionReferences(state, schema, event, strict);
+ const data = asEventRecord(event.data);
+ const providerId =
+ typeof data.id === 'string' && data.id.length > 0 ? data.id : event.id;
+
+ validateRequiredString(
+ providerId,
+ 'provider_id',
+ schema.tables.subscriptions.name,
+ );
+ validateRequiredString(
+ event.provider,
+ 'provider',
+ schema.tables.subscriptions.name,
+ );
+ validateRequiredBoolean(
+ event.sandbox,
+ 'sandbox',
+ schema.tables.subscriptions.name,
+ );
+ validateRequiredString(event.type, 'type', schema.tables.subscriptions.name);
+
+ const key = entityKey(event.provider, event.sandbox, providerId);
+ validateUniqueInsert(
+ strict,
+ state.subscriptions.has(key),
+ schema.tables.subscriptions.name,
+ providerId,
+ );
+
+ const now = event.createdAt ?? new Date().toISOString();
+ state.subscriptions.set(key, {
+ id: providerId,
+ provider: event.provider,
+ sandbox: event.sandbox,
+ eventId: event.id,
+ type: event.type,
+ status:
+ event.type === 'subscription.canceled'
+ ? 'canceled'
+ : typeof data.status === 'string'
+ ? data.status
+ : 'active',
+ createdAt: now,
+ updatedAt: event.updatedAt ?? now,
+ data: cloneValue(data),
+ raw: persistRaw ? (event.raw ?? getInternalRaw(event) ?? null) : null,
+ });
+}
+
+function insertSeedWebhookEvent(
+ state: MemoryDatabaseState,
+ event: MemorySeedWebhookEvent,
+ strict: boolean,
+ persistRaw: boolean,
+) {
+ validateRequiredString(event.deliveryId, 'deliveryId', 'webhookEvents');
+ validateRequiredString(event.provider, 'provider', 'webhookEvents');
+ validateRequiredBoolean(event.sandbox, 'sandbox', 'webhookEvents');
+ validateRequiredString(event.type, 'type', 'webhookEvents');
+
+ const key = entityKey(event.provider, event.sandbox, event.deliveryId);
+ validateUniqueInsert(
+ strict,
+ state.webhookEvents.has(key),
+ 'webhookEvents',
+ event.deliveryId,
+ );
+ const now = event.createdAt ?? new Date().toISOString();
+ state.webhookEvents.set(key, {
+ deliveryId: event.deliveryId,
+ provider: event.provider,
+ sandbox: event.sandbox,
+ type: event.type,
+ status: event.status ?? 'processing',
+ attempts: event.attempts ?? 1,
+ createdAt: now,
+ updatedAt: event.updatedAt ?? now,
+ processedAt: event.processedAt ?? null,
+ lastError: event.lastError ?? null,
+ data: cloneValue(event.data),
+ raw: persistRaw ? (event.raw ?? getInternalRaw(event) ?? null) : null,
+ });
+}
+
+function insertSeedProduct(
+ state: MemoryDatabaseState,
+ schema: ResolvedDatabaseSchema,
+ product: MemorySeedProduct,
+ strict: boolean,
+ persistRaw: boolean,
+) {
+ validateRequiredString(product.id, 'id', schema.tables.products.name);
+ validateRequiredString(
+ product.provider,
+ 'provider',
+ schema.tables.products.name,
+ );
+ validateRequiredBoolean(
+ product.sandbox,
+ 'sandbox',
+ schema.tables.products.name,
+ );
+
+ const key = entityKey(product.provider, product.sandbox, product.id);
+ validateUniqueInsert(
+ strict,
+ state.products.has(key),
+ schema.tables.products.name,
+ product.id,
+ );
+ const now = product.createdAt ?? new Date().toISOString();
+ state.products.set(key, {
+ ...product,
+ createdAt: now,
+ updatedAt: product.updatedAt ?? now,
+ raw: persistRaw ? (product.raw ?? null) : null,
+ });
+}
+
+function insertSeedPrice(
+ state: MemoryDatabaseState,
+ schema: ResolvedDatabaseSchema,
+ price: MemorySeedPrice,
+ strict: boolean,
+ persistRaw: boolean,
+) {
+ validateRequiredString(price.id, 'id', schema.tables.prices.name);
+ validateRequiredString(price.provider, 'provider', schema.tables.prices.name);
+ validateRequiredBoolean(price.sandbox, 'sandbox', schema.tables.prices.name);
+
+ if (typeof price.productId === 'string' && price.productId.length > 0) {
+ ensureProductExists(
+ state,
+ strict,
+ schema,
+ price.provider,
+ price.sandbox,
+ price.productId,
+ );
+ }
+
+ const key = entityKey(price.provider, price.sandbox, price.id);
+ validateUniqueInsert(
+ strict,
+ state.prices.has(key),
+ schema.tables.prices.name,
+ price.id,
+ );
+ const now = price.createdAt ?? new Date().toISOString();
+ state.prices.set(key, {
+ ...price,
+ createdAt: now,
+ updatedAt: price.updatedAt ?? now,
+ raw: persistRaw ? (price.raw ?? null) : null,
+ });
+}
+
+/**
+ * In strict mode, validates that the `customer_id`, `product_id`, and `price_id`
+ * referenced by a subscription event exist in the current state.
+ */
+export function validateSubscriptionReferences(
+ state: MemoryDatabaseState,
+ schema: ResolvedDatabaseSchema,
+ event: BasePaymeshEvent,
+ strict: boolean,
+) {
+ const data = asEventRecord(event.data);
+
+ if (typeof data.customer_id === 'string' && data.customer_id.length > 0) {
+ ensureCustomerExists(
+ state,
+ strict,
+ schema,
+ event.provider,
+ event.sandbox,
+ data.customer_id,
+ );
+ }
+
+ if (typeof data.product_id === 'string' && data.product_id.length > 0) {
+ ensureProductExists(
+ state,
+ strict,
+ schema,
+ event.provider,
+ event.sandbox,
+ data.product_id,
+ );
+ }
+
+ if (typeof data.price_id === 'string' && data.price_id.length > 0) {
+ ensurePriceExists(
+ state,
+ strict,
+ schema,
+ event.provider,
+ event.sandbox,
+ data.price_id,
+ );
+ }
+}
+
+function asEventRecord(value: unknown) {
+ return typeof value === 'object' && value !== null
+ ? (cloneValue(value) as Record)
+ : {};
+}
diff --git a/packages/memory/src/types.ts b/packages/memory/src/types.ts
new file mode 100644
index 0000000..265b9ca
--- /dev/null
+++ b/packages/memory/src/types.ts
@@ -0,0 +1,122 @@
+import type {
+ BaseAnyPayment,
+ BaseCustomer,
+ BasePaymeshEvent,
+ BasePix,
+ ProviderCatalogPrice,
+ ProviderCatalogProduct,
+} from 'paymesh';
+
+/** Optional timestamp fields that can be provided when seeding records. */
+export interface MemorySeedTimestamps {
+ /** ISO-8601 creation timestamp. */
+ createdAt?: string;
+ /** ISO-8601 last update timestamp. */
+ updatedAt?: string;
+}
+
+/** Customer record used for seeding the in-memory database. */
+export type MemorySeedCustomer = BaseCustomer &
+ MemorySeedTimestamps & {
+ /** Marks the customer as soft-deleted at seed time. */
+ deleted?: boolean;
+ /** Optional raw provider payload to persist when `persistRaw` is enabled. */
+ raw?: unknown;
+ };
+
+/** Payment record (checkout or invoice) used for seeding the in-memory database. */
+export type MemorySeedPayment = BaseAnyPayment &
+ MemorySeedTimestamps & {
+ /** Optional raw provider payload to persist when `persistRaw` is enabled. */
+ raw?: unknown;
+ };
+
+/** Pix payment record used for seeding the in-memory database. */
+export type MemorySeedPix = BasePix &
+ MemorySeedTimestamps & {
+ /** Optional raw provider payload to persist when `persistRaw` is enabled. */
+ raw?: unknown;
+ };
+
+/** Subscription event record used for seeding the in-memory database. */
+export type MemorySeedSubscription = BasePaymeshEvent &
+ MemorySeedTimestamps & {
+ /** Optional raw provider payload to persist when `persistRaw` is enabled. */
+ raw?: unknown;
+ };
+
+/**
+ * Webhook delivery event record used for seeding the in-memory database.
+ *
+ * Tracks delivery attempts, processing status, and error history for
+ * idempotent webhook handling.
+ */
+export interface MemorySeedWebhookEvent
+ extends BasePaymeshEvent,
+ MemorySeedTimestamps {
+ /** Unique delivery identifier used as the idempotency key. */
+ deliveryId: string;
+ /** Current processing status of the webhook event. */
+ status?: 'processing' | 'processed' | 'failed';
+ /** Number of delivery attempts made so far. */
+ attempts?: number;
+ /** Error message from the last failed processing attempt. */
+ lastError?: string | null;
+ /** ISO-8601 timestamp of when the event was successfully processed. */
+ processedAt?: string | null;
+ /** Optional raw provider payload to persist when `persistRaw` is enabled. */
+ raw?: unknown;
+}
+
+/** Catalog product record used for seeding the in-memory database. */
+export type MemorySeedProduct = ProviderCatalogProduct &
+ MemorySeedTimestamps & {
+ /** Provider identifier that owns this product. */
+ provider: string;
+ };
+
+/** Catalog price record used for seeding the in-memory database. */
+export type MemorySeedPrice = ProviderCatalogPrice &
+ MemorySeedTimestamps & {
+ /** Provider identifier that owns this price. */
+ provider: string;
+ };
+
+/**
+ * Seed data configuration for populating the in-memory database at creation time.
+ *
+ * All arrays are optional. Records are inserted with validation according to
+ * the current `strict` mode setting.
+ */
+export interface MemoryDatabaseSeed {
+ /** Customer records to seed. */
+ customers?: MemorySeedCustomer[];
+ /** Pix payment records to seed. */
+ pix?: MemorySeedPix[];
+ /** Checkout payment records to seed. */
+ checkouts?: MemorySeedPayment[];
+ /** Invoice payment records to seed. */
+ invoices?: MemorySeedPayment[];
+ /** Subscription event records to seed. */
+ subscriptions?: MemorySeedSubscription[];
+ /** Webhook delivery event records to seed. */
+ webhookEvents?: MemorySeedWebhookEvent[];
+ /** Catalog product records to seed. */
+ products?: MemorySeedProduct[];
+ /** Catalog price records to seed. */
+ prices?: MemorySeedPrice[];
+ /** Migration names to mark as already applied. */
+ migrations?: string[];
+}
+
+/**
+ * Configuration options for the {@link memory} database adapter.
+ */
+export interface MemoryDatabaseOptions {
+ /** When `true`, persists raw provider payloads alongside normalized records. Defaults to `false`. */
+ persistRaw?: boolean;
+ /** Seed data to populate the in-memory database at creation time. */
+ seed?: MemoryDatabaseSeed;
+ /** When `true`, enforces strict validation including referential integrity and uniqueness checks. Defaults to `true`. */
+ strict?: boolean;
+}
diff --git a/packages/memory/test/memory.test.ts b/packages/memory/test/memory.test.ts
new file mode 100644
index 0000000..ad4b7b0
--- /dev/null
+++ b/packages/memory/test/memory.test.ts
@@ -0,0 +1,245 @@
+import { describe, expect, test } from 'bun:test';
+import { createClient, defineProvider, withRaw } from 'paymesh';
+import { memory } from '../src/index';
+
+describe('@paymesh/memory', () => {
+ test('reads seeded customers through the client without hitting the provider', async () => {
+ let providerReads = 0;
+ const database = memory({
+ persistRaw: true,
+ seed: {
+ customers: [
+ {
+ id: 'cus_seed',
+ provider: 'stub',
+ sandbox: false,
+ email: 'seed@example.com',
+ raw: { seeded: true },
+ },
+ ],
+ },
+ });
+ const client = createClient({
+ provider: defineProvider({
+ id: 'stub',
+ isSandbox: () => false,
+ capabilities: {
+ checkout: true,
+ customers: true,
+ },
+ payments: {
+ create: async () => {
+ throw new Error('not used');
+ },
+ },
+ customers: {
+ get: async () => {
+ providerReads += 1;
+ throw new Error('not used');
+ },
+ upsert: async (_data, options) =>
+ withRaw(
+ {
+ id: 'cus_provider',
+ provider: 'stub',
+ sandbox: false,
+ },
+ { from: 'provider' },
+ options?.includeRaw,
+ ),
+ delete: async (_id, options) =>
+ withRaw(
+ {
+ id: 'cus_provider',
+ provider: 'stub',
+ sandbox: false,
+ deleted: true,
+ },
+ { from: 'provider' },
+ options?.includeRaw,
+ ),
+ },
+ }),
+ database,
+ includeRaw: true,
+ });
+
+ const customer = await client.customers.get('cus_seed');
+
+ expect(customer).toMatchObject({
+ id: 'cus_seed',
+ email: 'seed@example.com',
+ raw: { seeded: true },
+ });
+ expect(providerReads).toBe(0);
+ });
+
+ test('supports customer list pagination from seeded memory state', async () => {
+ const database = memory({
+ seed: {
+ customers: [
+ {
+ id: 'cus_1',
+ provider: 'stub',
+ sandbox: false,
+ email: 'ada@example.com',
+ createdAt: '2024-01-01T00:00:00.000Z',
+ },
+ {
+ id: 'cus_2',
+ provider: 'stub',
+ sandbox: false,
+ email: 'grace@example.com',
+ createdAt: '2024-01-01T00:00:00.000Z',
+ },
+ {
+ id: 'cus_3',
+ provider: 'stub',
+ sandbox: false,
+ email: 'linus@example.com',
+ createdAt: '2024-01-02T00:00:00.000Z',
+ },
+ ],
+ },
+ });
+ const firstPage = await database.repositories.customers.list(
+ createSchema(),
+ 'stub',
+ false,
+ { limit: 2 },
+ );
+ const secondPage = await database.repositories.customers.list(
+ createSchema(),
+ 'stub',
+ false,
+ {
+ limit: 2,
+ after: firstPage.next ?? undefined,
+ },
+ );
+
+ expect(firstPage.data.map((customer) => customer.id)).toEqual([
+ 'cus_1',
+ 'cus_2',
+ ]);
+ expect(firstPage.total).toBe(3);
+ expect(firstPage.next).toEqual(expect.any(String));
+ expect(secondPage.data.map((customer) => customer.id)).toEqual(['cus_3']);
+ });
+
+ test('validates related entities in strict mode during seed', () => {
+ expect(() =>
+ memory({
+ seed: {
+ prices: [
+ {
+ id: 'price_missing_product',
+ provider: 'stub',
+ sandbox: false,
+ productId: 'prod_missing',
+ },
+ ],
+ },
+ }),
+ ).toThrow(/Related product "prod_missing" does not exist/);
+ });
+
+ test('allows permissive seed and writes when strict is disabled', async () => {
+ const database = memory({
+ strict: false,
+ seed: {
+ prices: [
+ {
+ id: 'price_loose',
+ provider: 'stub',
+ sandbox: false,
+ productId: 'prod_missing',
+ },
+ ],
+ },
+ });
+
+ await expect(
+ database.repositories.pix.upsert(createSchema(), {
+ id: 'pix_1',
+ provider: 'stub',
+ sandbox: false,
+ amount: 1000,
+ currency: 'brl',
+ status: 'pending',
+ method: 'pix',
+ customer: { id: 'cus_missing' },
+ }),
+ ).resolves.toBeUndefined();
+ });
+
+ test('rolls back writes when a transaction fails', async () => {
+ const database = memory();
+
+ await expect(
+ database.transaction(async (tx) => {
+ await tx.repositories.customers.upsert(createSchema(), {
+ id: 'cus_tx',
+ provider: 'stub',
+ sandbox: false,
+ email: 'tx@example.com',
+ });
+ throw new Error('boom');
+ }),
+ ).rejects.toThrow('boom');
+
+ await expect(
+ database.repositories.customers.findByProviderId(
+ createSchema(),
+ 'stub',
+ false,
+ 'cus_tx',
+ ),
+ ).resolves.toBeNull();
+ });
+
+ test('throws a clear error for arbitrary SQL query execution', async () => {
+ const database = memory();
+
+ await expect(
+ database.query({
+ sql: 'select 1',
+ params: [],
+ }),
+ ).rejects.toMatchObject({
+ name: 'PaymeshError',
+ code: 'database_error',
+ message:
+ '@paymesh/memory does not execute arbitrary SQL. Use repository methods instead.',
+ });
+ });
+});
+
+function createSchema() {
+ return createClient({
+ provider: defineProvider({
+ id: 'stub',
+ isSandbox: () => false,
+ capabilities: {
+ checkout: true,
+ customers: true,
+ },
+ payments: {
+ create: async () => {
+ throw new Error('not used');
+ },
+ },
+ customers: {
+ get: async () => {
+ throw new Error('not used');
+ },
+ upsert: async () => {
+ throw new Error('not used');
+ },
+ delete: async () => {
+ throw new Error('not used');
+ },
+ },
+ }),
+ }).schema;
+}
diff --git a/packages/memory/tsconfig.json b/packages/memory/tsconfig.json
new file mode 100644
index 0000000..d6b0121
--- /dev/null
+++ b/packages/memory/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "paymesh": ["../paymesh/src/index.ts"]
+ },
+ "types": ["bun"]
+ },
+ "include": ["src/**/*.ts", "test/**/*.ts"]
+}
diff --git a/packages/memory/tsdown.config.mjs b/packages/memory/tsdown.config.mjs
new file mode 100644
index 0000000..c45c5c1
--- /dev/null
+++ b/packages/memory/tsdown.config.mjs
@@ -0,0 +1,13 @@
+import { defineConfig } from 'tsdown';
+
+export default defineConfig({
+ entry: ['./src/index.ts'],
+ format: ['esm', 'cjs'],
+ dts: true,
+ clean: true,
+ sourcemap: false,
+ minify: true,
+ target: 'node20',
+ outDir: 'dist',
+ external: ['paymesh'],
+});
diff --git a/packages/paymesh/README.md b/packages/paymesh/README.md
index a16517c..4371b6e 100644
--- a/packages/paymesh/README.md
+++ b/packages/paymesh/README.md
@@ -25,6 +25,10 @@
npm install paymesh @paymesh/stripe @paymesh/postgres
```
+
+ For tests, CI, and local examples, use @paymesh/memory instead of a production database adapter.
+
+
Quickstart
@@ -90,6 +94,10 @@ const pix = await paymesh.pix.create({
console.log(pix.copyPasteCode, pix.instructionsUrl);
```
+
+ For lightweight test setups, the same client can run with database: memory() from @paymesh/memory.
+
+
Built-in Hooks