Package reference
Mirrors the package README (single source). Install @basaltkit/subscriptions-prisma v3.0.0 — npm · source.
<p align="center"> <a href="https://basaltkit-docs.pages.dev"> <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440"> </a> </p>
@basaltkit/subscriptions-prisma
Prisma-backed implementations of every @basaltkit/subscriptions store — the subscription record, usage metering, webhook idempotency, the payment ledger and recurring subscriptions — for production databases (PostgreSQL, MySQL, …).
You bring a generated PrismaClient; the stores only touch the delegates they need (subscription, usageCounter, webhookEvent for prismaSubscriptionsStores; payment and recurringSubscription for prismaPaymentStores). The production counterpart to @basaltkit/subscriptions-sqlite.
pnpm add @basaltkit/subscriptions-prisma # peer: @basaltkit/subscriptions ; you already have @prisma/client1. Add the models
Copy the models from the bundled reference schema (prisma/schema.prisma in this package) into your schema.prisma:
model Subscription {
billableId String @id
plan String
period String
status String
trialEndsAt DateTime?
cancelAtPeriodEnd Boolean?
canceledAt DateTime?
gatewayRef String?
pendingPlan String?
pendingPeriod String?
@@map("subscriptions")
}
model UsageCounter {
billableId String
feature String
periodKey String
value Int @default(0)
@@id([billableId, feature, periodKey])
@@map("usage_counters")
}
model WebhookEvent {
id String @id
seenAt DateTime @default(now())
@@map("webhook_events")
}> pendingPlan / pendingPeriod are required, not optional extras. They carry the > checkout intent that @basaltkit/subscriptions refuses to promote until the gateway > confirms payment with a new subscription ref — the guard against plan escalation via an > abandoned checkout. PrismaSubscriptionStore.save always writes both (writing null > clears them), so a schema without the columns fails on every save.
And, if you use the payment ledger / reference-based recurring billing, these two as well — amount is BigInt (minor units) to avoid the 32-bit Int ceiling:
model Payment {
id String @id
status String @default("pending")
amount BigInt
billableId String?
reference String?
raw String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@map("payments")
}
model RecurringSubscription {
billableId String @id
plan String
amount BigInt
interval String
status String
paidThrough DateTime?
pendingPaymentId String?
customer String?
createdAt DateTime
updatedAt DateTime
@@index([status])
@@map("recurring_subscriptions")
}Then prisma migrate dev and prisma generate.
2. Wire the stores
prismaSubscriptionsStores(prisma) returns all three stores named to drop straight into subscriptionsPlugin — pass your client directly, no cast:
import { subscriptionsPlugin } from '@basaltkit/subscriptions'
import { prismaSubscriptionsStores } from '@basaltkit/subscriptions-prisma'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const s = prismaSubscriptionsStores(prisma)
createApp({
plugins: [subscriptionsPlugin({ plans, store: s.store, usage: s.usage, webhooks: s.webhooks })],
})Atomic usage metering
The metered consume() uses a conditional updateMany (value <= limit - amount) that the database's row lock serializes, so a plan quota is never overshot under concurrency. Webhook idempotency is an atomic createMany({ skipDuplicates: true }) claim, so a redelivered event is processed once across restarts and instances. consume()/increment() reject an amount that is not a positive integer with InvalidUsageAmountError (a negative amount would refund quota).
| Export | Contract | Model |
|---|---|---|
PrismaSubscriptionStore | SubscriptionStore | Subscription |
PrismaUsageStore | UsageStore (atomic consume) | UsageCounter |
PrismaWebhookStore | WebhookStore | WebhookEvent |
PrismaPaymentStore | PaymentStore | Payment |
PrismaRecurringStore | RecurringStore | RecurringSubscription |
Counter rows are seeded with createMany({ skipDuplicates: true }) rather than an upsert: two concurrent upserts of the same new row both miss and race to INSERT, failing with P2002 on a real database.
Payment ledger & recurring billing
import { PaymentLedger, RecurringReferenceBilling } from '@basaltkit/subscriptions'
import { prismaPaymentStores, prismaSubscriptionsStores } from '@basaltkit/subscriptions-prisma'
const s = prismaSubscriptionsStores(prisma)
const p = prismaPaymentStores(prisma)
const ledger = new PaymentLedger({ store: p.payments, webhooks: s.webhooks })
const billing = new RecurringReferenceBilling({ gateway, ledger, store: p.recurring })PrismaPaymentStore.create is an atomic idempotent insert (skipDuplicates), so a concurrent create neither throws nor clobbers. setStatus upserts, and falls back to an update when it loses a create race (P2002) — a webhook that beats the local record still settles.
MySQL
The reference schema above is written for PostgreSQL (and works on SQLite), where a bare String is TEXT. On MySQL Prisma makes it VARCHAR(191), and a server outside strict mode truncates a longer value silently — the write succeeds, and the value read back is not the one written. Two long webhook event ids cut to the same prefix make the second look already processed — that event is dropped — and a cut gateway raw payload is no longer valid JSON.
Copy
schema.mysql.prismainstead (exported as@basaltkit/subscriptions-prisma/schema.mysql.prisma;basalt prisma:syncpicks it when your datasource ismysql): the free-text columns are widened with native types, the keys stayVARCHAR(191)so they can be indexed.Turn on the guard, so a value that still would not fit is refused (
ColumnLengthError, codeCOLUMN_LENGTH_EXCEEDED, status 422, nothing written) instead of cut:tsprismaSubscriptionsStores(prisma, { columnLimits: 'mysql' }) prismaPaymentStores(prisma, { columnLimits: 'mysql' })'mysql'issubscriptionsMysqlColumnLimits— the capacities ofschema.mysql.prisma. A number is a limit in characters (VARCHAR(n)),{ bytes: n }a limit in UTF-8 bytes (theTEXTfamily). Widened a column yourself? Spread the preset and raise it:{ Payment: { ...subscriptionsMysqlColumnLimits.Payment, reference: 500 } }.Keep MySQL in strict mode (
STRICT_TRANS_TABLES) as well.
Unset (the default), nothing is checked — PostgreSQL and SQLite are unaffected. See the MySQL section of the persistence guide.
API reference
| Export | Signature | Purpose |
|---|---|---|
prismaSubscriptionsStores | (client: PrismaSubscriptionsClient, options?) => { store, usage, webhooks } | Named to drop straight into subscriptionsPlugin. |
prismaPaymentStores | (client: PrismaPaymentsClient, options?) => { payments, recurring } | For PaymentLedger / RecurringReferenceBilling. |
subscriptionsMysqlColumnLimits / ColumnLengthError | const / class | options.columnLimits ('mysql' or your own) refuses a value longer than its MySQL column — see MySQL. |
Both validate up front that the client actually has the delegates they need, and throw an actionable Error naming the model and how to add it — instead of a cryptic reading 'create' of undefined at the first write. A lazy/proxy client (as used by database-per-tenant) skips the check and is validated at first use instead.
Multi-tenant?
For database-per-tenant, route the stores through the active tenant's client — see the Database-per-tenant guide.
Typing note
PrismaSubscriptionsClient and PrismaPaymentsClient type delegate arguments as any (returns stay precise) so a real PrismaClient is assignable and passes directly — Prisma's generated method generics can't be reproduced by a hand-written interface.
Failure modes
Besides ColumnLengthError (the opt-in MySQL guard), this package defines no error classes of its own; domain errors come from @basaltkit/subscriptions (BILLING_QUOTA_EXCEEDED, …) and database errors from Prisma.
| Error | Code | HTTP | When |
|---|---|---|---|
Error (plain) | — | — | The Prisma client has no subscription / payment / recurringSubscription model. The message names the model and points at basalt prisma:sync or the bundled schema. |
ColumnLengthError | COLUMN_LENGTH_EXCEEDED | 422 | With columnLimits, a value is longer than its MySQL column. Thrown before the write — see MySQL. |
PrismaClientKnownRequestError | P2002 | — | A unique-constraint race. The payment stores catch it and retry as an update; elsewhere it surfaces. |
Symptoms:
Unknown argument 'pendingPlan'on every save — theSubscriptionmodel is missing the two pending columns. Add them and migrate.Unknown argument 'amount'/ a value out of range on payments —amountmust beBigInt, notInt; minor units overflow 32 bits quickly.- A quota is overshot under load — the conditional
updateManyguard only holds when every process shares one database. Check you aren't mixing in a memory store. - A redelivered webhook is processed twice —
webhookEventmust haveidas the primary key, socreateMany({ skipDuplicates: true })can be the atomic claim.
Guides: Billing · Payment references · Persistence · Database per tenant
License
MIT