Node ORM recipes
You never write a payment repository by hand. OpenReceive owns the
openreceive_payments logic. Your ORM provides two things:
- The migration.
npx openreceive scaffold payments --orm prisma(ordrizzle | typeorm | sequelize | knex,--dialect postgres | sqlite) writes one migration or schema file and a wiring guide. The file creates two tables,openreceive_paymentsandopenreceive_meta. Run it with your normal migration workflow. - The
dbhandle you pass tocreateHost({ db, ... }).
What to pass as db
| Stack | Pass |
|---|---|
| pg (node-postgres) | the Pool or Client directly |
| node:sqlite | the DatabaseSync directly |
| better-sqlite3 | the Database directly |
| Drizzle | the underlying driver (pg Pool or better-sqlite3) directly |
| Prisma | prismaDb(prisma, dialect) from @openreceive/http |
| Knex | knexDb(knex, dialect) from @openreceive/http |
| TypeORM | typeOrmDb(dataSource, dialect) from @openreceive/http |
| Sequelize | sequelizeDb(sequelize, dialect) from @openreceive/http |
A custom adapter is { dialect, query, transaction } (SqlAdapter):
dialectis"postgres"or"sqlite".queryruns one statement and returns the SELECT rows ([]for other statements).transactionruns a callback against a transactional client.
Host SQL reaches the driver unchanged. The library writes each of its own
statements in the dialect you declared: ? on sqlite, $1-style on postgres.
So an adapter passes SQL through exactly as written. A custom adapter must not
rewrite placeholders either. Renumbering ? to $1 would break statements
that were already correct. prismaDb’s statement router exists to prevent
exactly that failure. See Storage.
You only need a custom adapter for a stack the factories below do not cover.
Prisma, Knex, TypeORM, Sequelize
@openreceive/http ships a named factory for each ORM. The parameter types are
structural, so no ORM dependency is added and your existing handle passes
straight in. dialect is a required argument because nothing on the handles
states it reliably. For Prisma, match your datasource provider:
import { knexDb, prismaDb, sequelizeDb, typeOrmDb } from "@openreceive/http";
createHost({ db: prismaDb(prisma, "postgres"), ... });
createHost({ db: knexDb(knex, "sqlite"), ... });
createHost({ db: typeOrmDb(dataSource, "postgres"), ... });
createHost({ db: sequelizeDb(sequelize, "postgres"), ... });
Use the factory for your ORM. Each one makes settlement SQL run in the same
transaction as onPaid.
A Prisma trap: the Prisma CLI loads .env automatically for every command. If
that file holds a DATABASE_URL that points to a container path, migrations run
from the host machine break. See Deploying → Node in Docker.
Schema and onPaid
The scaffolded migration renders the canonical DDL in @openreceive/core
(payments-ddl.ts). paymentsSchemaSql(dialect) renders from the same source,
so the two cannot drift apart. Keep every column:
| Column | Notes |
|---|---|
reference |
Indexed but not unique. |
payment_hash |
Unique. A CHECK constraint enforces 64 lowercase hex. |
status |
A CHECK constraint limits it to the five statuses. |
status_reason |
Nullable detail for operators. |
paid_at |
Nullable, write-once. |
expires_at |
Required. |
created_at |
The exact time the wallet created the invoice. |
updated_at |
Set from the local clock. |
inserted_at |
Write-once. |
checkout_data |
The payer-safe JSON snapshot (BOLT11, amount, timestamps). |
swap_data |
Server-only. Never reaches a serializer, log, or browser. |
client_ip |
Nullable, with its (client_ip, inserted_at) index. Database-backed rate limiting relies on it. |
See Payment storage for what each column means in full.
The same file also creates openreceive_meta. Keep it. The library uses it to
share one wallet scan across every instance. See
Payment storage.
onPaid({ reference, paymentHash, paidAt, details?, query }) runs inside the
library’s settlement transaction. It runs only for the first settled attempt
for a reference. Use query, with statements written for your own dialect, to
update your order or insert an outbox row in that transaction. Do not use your
ORM’s separate connection there. Never map swap_data into an API serializer,
log, or browser bundle.
The “first settled attempt” guarantee covers every settlement path that OpenReceive owns. It cannot cover fulfillment your application triggers elsewhere. An admin action, a second payment processor, or a replayed job might also fulfill an order. If so, they race each other. Make the state change itself the guard:
const onPaid = async ({ reference, paidAt, query }) => {
const claimed = await query(
`UPDATE orders SET state = 'paid', paid_at = $1
WHERE id = $2 AND state = 'awaiting_payment' RETURNING id`,
[paidAt, reference],
);
if (claimed.length === 0) return; // someone else already fulfilled it
// Same transaction: enqueue the shipping/email work rather than doing it
// inline. Anything that reaches outside the transaction survives a rollback
// and runs again on the retry.
await query("INSERT INTO outbox (kind, reference) VALUES ($1, $2)", ["order_paid", reference]);
};
Every scaffolded file includes a longer version of this note.
Implement the full PaymentRepository interface only if no supported handle
or adapter can reach your storage. Pass it as payments instead of db. This
is an advanced escape hatch, and it makes you responsible for:
- commit locking
- write-once settlement
- reconciliation transitions
- the lease and progress pair
claimReconcileGateandcheckpointReconcileGate
Setting opportunisticReconcile: false turns off only the triggers on
requests. A worker you run explicitly still needs the durable gate.
recordSettlementWithFulfillment(input, fulfill) must await fulfill with a
typed transaction handle before it commits. Never call it after committing a
boolean claim. See upgrade and recovery.