diff --git a/.changeset/tidy-bees-jam.md b/.changeset/tidy-bees-jam.md new file mode 100644 index 0000000..b4c38f1 --- /dev/null +++ b/.changeset/tidy-bees-jam.md @@ -0,0 +1,7 @@ +--- +"@paymesh/memory": minor +"@paymesh/cli": minor +"paymesh": patch +--- + +Add a first-party in-memory database adapter with strict validation, seeding, CLI awareness, and documentation updates. diff --git a/apps/web/content/docs/database/memory.mdx b/apps/web/content/docs/database/memory.mdx new file mode 100644 index 0000000..0dc8f4e --- /dev/null +++ b/apps/web/content/docs/database/memory.mdx @@ -0,0 +1,105 @@ +--- +title: Memory +description: Use the memory adapter for tests, CI, local demos, and ephemeral examples where you want the Paymesh database contract without durable SQL storage. +--- + +## Overview + +`@paymesh/memory` is a first-party in-memory adapter for Paymesh. + +It is designed for: + +- tests +- CI +- local demos +- examples that should stay dependency-light + +It implements the same repository contract as the SQL-backed adapters, but keeps everything process-local and ephemeral. + +## Installation + + + +## Usage + +```ts title="src/lib/paymesh.ts" +import { createClient } from "paymesh"; +import { memory } from "@paymesh/memory"; +import { stripe } from "@paymesh/stripe"; + +export const paymesh = createClient({ + provider: stripe({ + secret: "sk_test_123", + webhookSecret: "whsec_123", + }), + database: memory({ + seed: { + customers: [ + { + id: "cus_seed", + provider: "stripe", + sandbox: true, + email: "ada@example.com", + }, + ], + }, + }), +}); +``` + +## Options + +| Option | Default | Description | +| --- | --- | --- | +| `persistRaw` | `false` | Stores raw provider payloads in memory alongside normalized records. | +| `strict` | `true` | Enforces relational-style validation such as required fields, related entity existence, and duplicate unique ids. | +| `seed` | `undefined` | Preloads built-in Paymesh tables when the adapter is created. | + +## Seeded startup data + +Use `seed` when your tests or examples need initial local state: + +```ts +memory({ + seed: { + customers: [ + { + id: "cus_123", + provider: "stripe", + sandbox: true, + email: "ada@example.com", + }, + ], + products: [ + { + id: "prod_123", + provider: "stripe", + sandbox: true, + name: "Pro", + }, + ], + }, +}) +``` + +When `strict` is enabled, seed data is validated during initialization so invalid fixtures fail early. + +## CLI behavior + +The memory adapter is not a SQL backend. + +That means: + +- `paymesh generate` is skipped +- `paymesh migrate` is skipped +- `paymesh status` still works, but reports the adapter as ephemeral instead of trying to run SQL-backed migration and count queries + +## When to choose this adapter + +Use `@paymesh/memory` when: + +- you want a first-party test adapter +- CI should not depend on a database container +- you need lightweight examples or prototypes + +Use `@paymesh/postgres`, `@paymesh/drizzle`, or `@paymesh/prisma` when you need durable storage. diff --git a/apps/web/content/docs/database/overview.mdx b/apps/web/content/docs/database/overview.mdx index 1b05f28..14cf98d 100644 --- a/apps/web/content/docs/database/overview.mdx +++ b/apps/web/content/docs/database/overview.mdx @@ -49,11 +49,12 @@ That means database shape is not just a static package concern. It depends on th First-party adapters in this repository are: +- `@paymesh/memory` - `@paymesh/postgres` - `@paymesh/drizzle` - `@paymesh/prisma` -All three expose the same Paymesh database driver contract: +All four expose the same Paymesh database driver contract: - `query` - `execute` @@ -61,7 +62,7 @@ All three expose the same Paymesh database driver contract: - `repositories` - optional `close` -The difference is where the SQL ultimately runs. +The difference is where persistence ultimately runs. `@paymesh/memory` is repository-backed and ephemeral. The others execute SQL through their respective runtimes. ## `persistRaw` @@ -112,6 +113,7 @@ Use `schema.customTables` when you need additional app- or plugin-owned tables. ## Which adapter should you pick +- use `@paymesh/memory` for tests, CI, demos, and local examples - use `@paymesh/postgres` when you want the simplest direct path with `pg` - use `@paymesh/drizzle` when your application already standardizes on Drizzle - use `@paymesh/prisma` when Prisma is your application’s database boundary diff --git a/apps/web/content/docs/guides/database.mdx b/apps/web/content/docs/guides/database.mdx index 491bbeb..8d608f9 100644 --- a/apps/web/content/docs/guides/database.mdx +++ b/apps/web/content/docs/guides/database.mdx @@ -32,6 +32,7 @@ The schema layer knows about these built-in table keys: ## Adapter choice +- Use `@paymesh/memory` for tests, CI, and non-production examples. - Use `@paymesh/postgres` for the shortest path to direct Postgres persistence. - Use `@paymesh/drizzle` if your app already owns a Drizzle instance. - Use `@paymesh/prisma` if your stack already standardizes on Prisma. diff --git a/apps/web/content/docs/installation.mdx b/apps/web/content/docs/installation.mdx index b3c503a..62a8805 100644 --- a/apps/web/content/docs/installation.mdx +++ b/apps/web/content/docs/installation.mdx @@ -77,7 +77,36 @@ description: Install Paymesh, wire a provider, optionally add persistence, and m For anything beyond a toy integration, add one database adapter. - + + + + + ```ts title="src/lib/paymesh.ts" + import { createClient } from "paymesh"; + import { memory } from "@paymesh/memory"; + import { stripe } from "@paymesh/stripe"; + + export const paymesh = createClient({ + provider: stripe({ + secret: "sk_test_123", + webhookSecret: "whsec_123", + }), + database: memory({ + seed: { + customers: [ + { + id: "cus_seed", + provider: "stripe", + sandbox: true, + email: "ada@example.com", + }, + ], + }, + }), + }); + ``` + + @@ -148,6 +177,10 @@ description: Install Paymesh, wire a provider, optionally add persistence, and m `persistRaw: true` stores provider payloads alongside normalized data. Use it when you need reconciliation, replay analysis, or provider-specific debugging. Leave it off when normalized records are enough. + + + Use `@paymesh/memory` only for tests, CI, demos, and local examples. For production persistence, choose Postgres, Drizzle, or Prisma. + diff --git a/apps/web/content/docs/introduction.mdx b/apps/web/content/docs/introduction.mdx index 5084982..94f54d2 100644 --- a/apps/web/content/docs/introduction.mdx +++ b/apps/web/content/docs/introduction.mdx @@ -26,7 +26,7 @@ At runtime, Paymesh is built from four layers: 1. A `client` created with `createClient`. 2. A `provider` such as `@paymesh/stripe` or `@paymesh/polar`. -3. An optional `database` adapter such as `@paymesh/postgres`, `@paymesh/drizzle`, or `@paymesh/prisma`. +3. An optional `database` adapter such as `@paymesh/memory`, `@paymesh/postgres`, `@paymesh/drizzle`, or `@paymesh/prisma`. 4. Optional `plugins` such as `@paymesh/dash` and `@paymesh/audit-logs`. ```ts title="src/lib/paymesh.ts" @@ -76,7 +76,7 @@ The current repository exposes these first-party modules: | --- | --- | | Core client | `paymesh` | | Providers | `@paymesh/stripe`, `@paymesh/polar`, `@paymesh/abacatepay` | -| Database adapters | `@paymesh/postgres`, `@paymesh/drizzle`, `@paymesh/prisma` | +| Database adapters | `@paymesh/memory`, `@paymesh/postgres`, `@paymesh/drizzle`, `@paymesh/prisma` | | Framework adapters | `@paymesh/next`, `@paymesh/express`, `@paymesh/fastify`, `@paymesh/hono`, `@paymesh/elysia` | | Plugins | `@paymesh/dash`, `@paymesh/audit-logs` | | Tooling | `@paymesh/cli` | diff --git a/bun.lock b/bun.lock index cf73e7f..743b8d3 100644 --- a/bun.lock +++ b/bun.lock @@ -174,6 +174,16 @@ "paymesh": ">=0.0.0", }, }, + "packages/memory": { + "name": "@paymesh/memory", + "version": "0.0.0", + "devDependencies": { + "paymesh": "workspace:*", + }, + "peerDependencies": { + "paymesh": ">=0.0.0", + }, + }, "packages/next": { "name": "@paymesh/next", "version": "0.0.0", @@ -516,6 +526,8 @@ "@paymesh/mcp": ["@paymesh/mcp@workspace:packages/mcp"], + "@paymesh/memory": ["@paymesh/memory@workspace:packages/memory"], + "@paymesh/next": ["@paymesh/next@workspace:packages/next"], "@paymesh/polar": ["@paymesh/polar@workspace:packages/polar"], diff --git a/lefthook.yml b/lefthook.yml index d471fc5..90ecf08 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -19,4 +19,34 @@ pre-commit: test: run: bun run test types: - run: bun run types + run: | + FILES=$(git diff --name-only --cached --diff-filter=ACMR) + + if [ -z "$FILES" ]; then + exit 0 + fi + + if echo "$FILES" | grep -q '^tsconfig.base.json$'; then + bun run types + exit $? + fi + + FILTERS=$(echo "$FILES" | awk -F/ ' + /^packages\/[^/]+\// { + if ($2 == "paymesh") print "paymesh"; + else print "@paymesh/" $2; + } + /^apps\/[^/]+\// { + print "./apps/" $2; + } + ' | sort -u) + + if [ -z "$FILTERS" ]; then + exit 0 + fi + + echo "$FILTERS" | while IFS= read -r filter; do + if [ -n "$filter" ]; then + bun run --filter "$filter" typecheck || exit $? + fi + done diff --git a/package.json b/package.json index 1a7a811..0d69c1a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "check": "biome check .", "check:write": "biome check --write .", "typecheck": "bun run typecheck:packages", - "typecheck:packages": "bun run --filter './packages/{paymesh,audit-logs,postgres,drizzle,prisma,stripe,polar,abacatepay,express,fastify,hono,elysia,next,cli,mcp}' typecheck", + "typecheck:packages": "bun run --filter './packages/{paymesh,audit-logs,memory,postgres,drizzle,prisma,stripe,polar,abacatepay,express,fastify,hono,elysia,next,cli,mcp}' typecheck", "typecheck:apps": "bun run --filter './apps/*' typecheck", "types": "bun run typecheck", "test": "bun test --pass-with-no-tests", diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index fd9f17f..5be701b 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -4,6 +4,7 @@ import { confirm, isCancel, log, text } from '@clack/prompts'; import type { Command } from 'commander'; import pc from 'picocolors'; import { loadClient } from '../lib/client'; +import { isMemoryDatabase } from '../lib/database'; import { DEFAULT_MIGRATIONS_DIR, planGenerateMigrations, @@ -31,6 +32,14 @@ export function registerGenerateCommand(program: Command) { explicitPath: options.client, }); + if (isMemoryDatabase(client.database)) { + logInfo( + 'Configured client uses @paymesh/memory. SQL migration generation does not apply.', + ); + + return; + } + const paymeshDir = path.join(process.cwd(), 'paymesh'); const isFirstRun = !existsSync(paymeshDir); diff --git a/packages/cli/src/commands/migrate.ts b/packages/cli/src/commands/migrate.ts index a9f763e..07e0bf9 100644 --- a/packages/cli/src/commands/migrate.ts +++ b/packages/cli/src/commands/migrate.ts @@ -1,6 +1,7 @@ import type { Command } from 'commander'; import { PaymeshError } from 'paymesh'; import { loadClient } from '../lib/client'; +import { isMemoryDatabase } from '../lib/database'; import { getAppliedPaymeshMigrations, getExpectedMigrations, @@ -31,6 +32,14 @@ export function registerMigrateCommand(program: Command) { message: 'The configured client does not define a database', }); + if (isMemoryDatabase(client.database)) { + logInfo( + 'Configured client uses @paymesh/memory. SQL migrations do not apply.', + ); + + return; + } + try { const migrationsDir = resolveMigrationsDir(process.cwd(), options.dir); const historyPath = resolveHistoryPath(process.cwd()); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index ccc6276..fc1aebe 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -2,6 +2,7 @@ import type { Command } from 'commander'; import { version } from 'src'; import { printWelcome } from 'src/lib/style'; import { loadClient } from '../lib/client'; +import { isMemoryDatabase } from '../lib/database'; import { getAppliedPaymeshMigrations, getMigrationHistoryStatus, @@ -31,11 +32,26 @@ export function registerStatusCommand(program: Command) { try { const migrationsDir = resolveMigrationsDir(process.cwd(), options.dir); const historyPath = resolveHistoryPath(process.cwd()); + const memoryDatabase = isMemoryDatabase(client.database); const [history, applied] = await Promise.all([ - getMigrationHistoryStatus(migrationsDir, historyPath, client.schema), + memoryDatabase + ? Promise.resolve({ + exists: false, + valid: true, + missingFiles: [], + checksumMismatches: [], + migrations: [], + }) + : getMigrationHistoryStatus( + migrationsDir, + historyPath, + client.schema, + ), client.database == null ? Promise.resolve([]) - : getAppliedPaymeshMigrations(client.database, client.schema), + : memoryDatabase + ? Promise.resolve([]) + : getAppliedPaymeshMigrations(client.database, client.schema), ]); const expected = history.migrations; const status = await getPaymeshStatus( @@ -55,6 +71,9 @@ export function registerStatusCommand(program: Command) { console.log( ` Database ${status.database.configured ? formatState(status.database.connected ? 'connected' : 'configured') : formatState('not configured', 'warn')}`, ); + console.log( + ` Adapter ${formatValue(status.database.adapter ?? 'none')}`, + ); console.log( ` History ${status.history.exists ? (status.history.valid ? formatState('valid') : formatState('invalid', 'bad')) : formatState('missing', 'warn')}`, ); diff --git a/packages/cli/src/lib/database.ts b/packages/cli/src/lib/database.ts new file mode 100644 index 0000000..7dd31b0 --- /dev/null +++ b/packages/cli/src/lib/database.ts @@ -0,0 +1,7 @@ +import type { PaymeshDatabaseDriver } from 'paymesh'; + +export function isMemoryDatabase( + database: Pick | null | undefined, +) { + return database?.id === 'memory'; +} diff --git a/packages/cli/src/lib/status.ts b/packages/cli/src/lib/status.ts index 960e896..b3e314d 100644 --- a/packages/cli/src/lib/status.ts +++ b/packages/cli/src/lib/status.ts @@ -1,4 +1,5 @@ import type { PaymeshClient } from 'paymesh'; +import { isMemoryDatabase } from './database'; import type { PaymeshMigrationHistoryStatus } from './migrations'; import { compileQuery, tableName } from './sql'; @@ -11,6 +12,8 @@ export interface CliStatus { database: { configured: boolean; connected: boolean; + adapter: string | null; + ephemeral: boolean; persistRaw: boolean; }; migrations: { @@ -59,6 +62,8 @@ export async function getPaymeshStatus( database: { configured: Boolean(client.database), connected: false, + adapter: client.database?.id ?? null, + ephemeral: isMemoryDatabase(client.database), persistRaw: client.database?.persistRaw ?? false, }, migrations: { @@ -85,6 +90,12 @@ export async function getPaymeshStatus( if (!client.database) return status; + if (isMemoryDatabase(client.database)) { + status.database.connected = true; + + return status; + } + const [counts] = await client.database.query<{ pix_count: string; product_count: string; diff --git a/packages/cli/src/shared/database.ts b/packages/cli/src/shared/database.ts index f98e10d..6e3bac9 100644 --- a/packages/cli/src/shared/database.ts +++ b/packages/cli/src/shared/database.ts @@ -1,4 +1,11 @@ export const DATABASE_ADAPTERS = [ + { + value: 'memory', + label: 'Memory adapter with @paymesh/memory', + package: '@paymesh/memory', + deps: [], + devDeps: [], + }, { value: 'postgres', label: 'Postgres (raw SQL with @paymesh/postgres)', diff --git a/packages/cli/src/shared/generators.ts b/packages/cli/src/shared/generators.ts index 45c48a0..eac0912 100644 --- a/packages/cli/src/shared/generators.ts +++ b/packages/cli/src/shared/generators.ts @@ -22,6 +22,7 @@ export function generateClientCode( lines.push(`import { ${provider} } from '@paymesh/${provider}';`); const dbImports: Record = { + memory: "import { memory } from '@paymesh/memory';", postgres: "import { postgres } from '@paymesh/postgres';", prisma: "import { prisma } from '@paymesh/prisma';", drizzle: "import { drizzle } from '@paymesh/drizzle';", @@ -44,6 +45,7 @@ export function generateClientCode( lines.push(` }),`); const dbInits: Record = { + 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