Gating features
The Items module is the live example (backend/src/items/items.service.ts,
free plan caps at 100). Abridged:
async create(input: CreateItemArgs): Promise<ItemRow> {
// Before the transaction, never inside it.
const entitlement = await this.billingService.getEntitlement(input.createdBy);
if (entitlement.paidAccess) {
// No cap, nothing to serialize.
const rows = await this.db<ItemRow>('items').insert({ /* ... */ }).returning('*');
return rows[0];
}
return this.db.transaction(async (trx) => {
// Lock the users row so concurrent creates for one user queue up.
await trx('users').where({ id: input.createdBy }).forUpdate().first();
// Counts the user's items and throws ForbiddenException at the cap.
await this.enforceItemLimit(trx, input.createdBy);
const rows = await trx<ItemRow>('items').insert({ /* ... */ }).returning('*');
return rows[0];
});
}
getEntitlement takes a pool connection of its own, so calling it inside
the transaction lets enough concurrent creates pin every connection and
deadlock the pool. The FOR UPDATE lock on the users row is what stops
two concurrent creates both counting 99 and both inserting; it has to be
users, because a first create has no items row to lock.
On deploys without billing, everyone is free-plan: gate on limits, not on "is billing enabled".