Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions docs/DATABASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,49 @@

Postgres via Prisma. Schema: [`prisma/schema.prisma`](../prisma/schema.prisma).

## Connection pool configuration (#215)

Prisma maintains a Postgres connection pool per `PrismaClient` instance,
sized by **query engine defaults** unless the `DATABASE_URL` overrides it:

| Param | Default | Meaning |
| ----------------- | ---------------------------- | ---------------------------------------------- |
| `connection_limit`| `num_cpus * 2 + 1` | Max pool size (physical connections) |
| `pool_timeout` | `10` seconds | How long a query waits for a free connection |
| `connect_timeout` | `5` seconds | TCP connect handshake timeout |

Because the default scales with the **host's** CPU count, it is wrong on
container platforms where `num_cpus` is the node's, not the container's —
set `connection_limit` explicitly everywhere except tiny local setups:

```
DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=10&pool_timeout=10&connect_timeout=5"
```

### Guidance by environment

| Environment | Suggested `connection_limit` | Why |
| ---------------------------------- | ---------------------------- | --- |
| Local dev / `docker-compose.yml` | `5` (or omit) | One developer, default engine sizing is fine |
| API pods on Kubernetes | `5–10` per pod | `pods * limit` must stay below Postgres `max_connections` minus superuser/system reserve (~10%) |
| Migration / one-off CLI runs | `2` | Migrations don't need a large pool |
| Serverless / Vercel functions | `1` **and** a pooled proxy (PgBouncer in transaction mode or Supabase/Neon pooled URL on port 6543) | Many ephemeral runtimes × per-runtime pools exhaust the database instantly |

Rules of thumb:

- **Budget first:** `sum(connection_limit across all deployable processes)
≤ max_connections - reserve`. Check `SHOW max_connections;` and every
deployment's replica count before raising limits.
- **Watch for pool timeouts:** `P2024` ("Timed out fetching a connection
from the pool") means queries are holding connections too long (long
interactive transactions) or the pool is undersized — prefer shrinking
transaction scopes (see #217) before raising the limit.
- **One pool per process, not per request:** the `PrismaService` singleton
already guarantees this; never construct `PrismaClient` inside request
handlers.
- Postgres reserves superuser slots, so keep the total well under
`max_connections` (default `100`) — roughly 80% as a ceiling.

## Key relationships

- `User` — `OrganizationMember` (many-to-many via join table) — `Organization`
Expand Down Expand Up @@ -62,6 +105,67 @@ Migration: `prisma/migrations/20260926100000_ticket_owner_status_index/`.
Supports `WHERE "ownerId" = $1 [AND "status" = $2] ORDER BY "createdAt" DESC`
as a single index scan.

## Resale ticket/status index (#214)

Resale lookups filter listings by ticket and status — active listing per
ticket, ticket-scoped feeds, and expiry sweeps all issue:

```sql
WHERE "ticketId" = $1 [AND "status" = $2]
```

Covered by a composite index declared in the schema and migration:

```prisma
@@index([ticketId, status]) // on ResaleListing
```

```sql
CREATE INDEX "ResaleListing_ticketId_status_idx"
ON "ResaleListing"("ticketId", "status");
```

Migration: `prisma/migrations/20260926130000_resale_ticket_status_index/`.

## One ACTIVE resale listing per ticket (#216)

Nothing else stops two `ACTIVE` `ResaleListing` rows for the same ticket.
Enforced by a **partial** unique index (raw SQL — Prisma cannot express
`WHERE`):

```sql
CREATE UNIQUE INDEX "ResaleListing_ticketId_active_key"
ON "ResaleListing"("ticketId")
WHERE "status" = 'ACTIVE';
```

- Concurrent `confirmListForResale` calls for the same ticket cannot both
succeed: the loser gets a `P2002` that `TicketsService` translates to
`409 Conflict`.
- Non-`ACTIVE` rows (`SOLD`, `CANCELLED`) are excluded, so a ticket can be
re-listed after its listing is sold or cancelled while the historical
listing rows stay intact.
- Migration: `prisma/migrations/20260926140000_resale_listing_active_partial_unique/`.
- Tested against the schema: two concurrent ACTIVE creates — exactly one
succeeds, the other maps to `409 Conflict`.

## Purchase concurrency & row-level locking (#217)

`quantityIssued` increments (`confirmIssue`, `confirmPurchase`) run inside
the same interactive transaction as the ticket insert, guarded by a
row-level lock:

```sql
SELECT "quantityIssued", "quantityTotal" FROM "TicketType" WHERE "id" = $1 FOR UPDATE
```

Concurrent transactions serialize on the `TicketType` row; each one
re-checks `quantityIssued < quantityTotal` **after** acquiring the lock, so
overselling `quantityTotal` is impossible even when the pre-transaction
capacity check raced. Losing transactions abort with
`TICKET_TYPE_SOLD_OUT` (`409` via the domain exception filter).


## Soft-delete for Event and Organization (#207)

`Event.deletedAt` and `Organization.deletedAt` (`NULL` = live) replace
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- #214 — resale lookups filter by ticket and status
-- (e.g. active listings per ticket, listing feeds, expiry sweeps).
-- Supports: WHERE "ticketId" = $1 [AND "status" = $2] as a single index scan.
CREATE INDEX IF NOT EXISTS "ResaleListing_ticketId_status_idx"
ON "ResaleListing"("ticketId", "status");
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- #216 — nothing in the schema prevented two ACTIVE ResaleListing rows for
-- a ticket. Partial unique index (Prisma cannot express WHERE, so this is
-- raw SQL by design): concurrent creates for the same ticket cannot both
-- succeed — the loser gets a P2002 that TicketsService maps to 409.
-- Non-ACTIVE rows are excluded so a ticket can be re-listed after its
-- listing is sold or cancelled while history stays intact.
CREATE UNIQUE INDEX IF NOT EXISTS "ResaleListing_ticketId_active_key"
ON "ResaleListing"("ticketId")
WHERE "status" = 'ACTIVE';
3 changes: 3 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,9 @@ model ResaleListing {
priceHistory ResalePriceHistory[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

// #214 — resale lookups filter by ticket and status.
@@index([ticketId, status])
}

model ResalePriceHistory {
Expand Down
114 changes: 114 additions & 0 deletions src/tickets/tickets.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ListingInactiveError,
TicketTypeSoldOutError,
} from '../common/errors/domain.error';
import { Prisma } from '@prisma/client';
import { TicketsService } from './tickets.service';
import type { PrismaService } from '../prisma/prisma.service';
import type { OrganizationsService } from '../organizations/organizations.service';
Expand Down Expand Up @@ -53,6 +54,7 @@ function buildTicketType(overrides: Partial<Record<string, unknown>> = {}) {
describe('TicketsService', () => {
let service: TicketsService;
let prisma: {
$queryRaw: jest.Mock;
ticketType: { findUnique: jest.Mock; update: jest.Mock };
ticket: {
findUnique: jest.Mock;
Expand All @@ -69,6 +71,7 @@ describe('TicketsService', () => {
findMany: jest.Mock;
count: jest.Mock;
findUnique: jest.Mock;
findFirst: jest.Mock;
update: jest.Mock;
};
resalePriceHistory: {
Expand All @@ -85,6 +88,9 @@ describe('TicketsService', () => {

beforeEach(() => {
prisma = {
$queryRaw: jest
.fn()
.mockResolvedValue([{ quantityIssued: 0, quantityTotal: 100 }]),
ticketType: { findUnique: jest.fn(), update: jest.fn() },
ticket: {
findUnique: jest.fn(),
Expand All @@ -101,6 +107,7 @@ describe('TicketsService', () => {
findMany: jest.fn(),
count: jest.fn().mockResolvedValue(0),
findUnique: jest.fn(),
findFirst: jest.fn().mockResolvedValue(null),
update: jest.fn(),
},
resalePriceHistory: {
Expand Down Expand Up @@ -252,6 +259,60 @@ describe('TicketsService', () => {
});
});

it('locks the TicketType row and re-checks capacity under the lock (#217)', async () => {
prisma.ticketType.findUnique.mockResolvedValue(buildTicketType());
prisma.user.findUnique.mockResolvedValue(
createUser({ id: 'buyer-1', stellarPublicKey: 'GBUYER' }),
);
prisma.ticketType.update.mockResolvedValue({});
prisma.ticket.create.mockResolvedValue(
createTicket({ id: 'ticket-new', ownerId: 'buyer-1' }),
);

await service.confirmIssue(
'organizer-1',
'tt-1',
'buyer-1',
'GBUYER',
undefined,
'signed-xdr',
);

// The row lock precedes the increment inside the same transaction.
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
const [query] = prisma.$queryRaw.mock.calls[0];
expect(query[0]).toContain('FOR UPDATE');
expect(prisma.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
prisma.ticketType.update.mock.invocationCallOrder[0],
);
});

it('uses the locked row as the capacity authority, not the stale read (#217)', async () => {
prisma.ticketType.findUnique.mockResolvedValue(buildTicketType());
prisma.user.findUnique.mockResolvedValue(
createUser({ id: 'buyer-1', stellarPublicKey: 'GBUYER' }),
);
// The stale pre-transaction read said capacity was available, but the
// locked row shows a concurrent transaction already filled the tier.
prisma.$queryRaw.mockResolvedValueOnce([
{ quantityIssued: 100, quantityTotal: 100 },
]);

await expect(
service.confirmIssue(
'organizer-1',
'tt-1',
'buyer-1',
'GBUYER',
undefined,
'signed-xdr',
),
).rejects.toBeInstanceOf(TicketTypeSoldOutError);

expect(prisma.ticketType.update).not.toHaveBeenCalled();
expect(prisma.ticket.create).not.toHaveBeenCalled();
});

it('rejects a duplicate assigned seat for the same event (#211)', async () => {
prisma.ticketType.findUnique.mockResolvedValue(buildTicketType());
prisma.ticket.findFirst.mockResolvedValueOnce({ id: 'existing-ticket' });
Expand Down Expand Up @@ -489,6 +550,59 @@ describe('TicketsService', () => {
});
});

it('rejects listing a ticket that already has an ACTIVE listing (#216)', async () => {
prisma.ticket.findUnique.mockResolvedValue({
id: 'ticket-1',
ownerId: 'owner-1',
chainTicketId: 7n,
event: {
organizationId: 'org-1',
organization: { stellarAccount: 'GORG' },
maxResaleMultiplierBps: 20_000,
},
ticketType: { price: 1_000n },
});
prisma.resaleListing.findFirst.mockResolvedValueOnce({ id: 'existing-listing' });

await expect(
service.confirmListForResale('owner-1', 'ticket-1', '1200', 'signed-xdr'),
).rejects.toBeInstanceOf(ConflictException);

// Fail fast: the chain is never touched and no listing row is written.
expect(stellar.submitSignedTransaction).not.toHaveBeenCalled();
expect(prisma.resaleListing.create).not.toHaveBeenCalled();
});

it('maps the DB unique-index violation to 409 when a concurrent create wins (#216)', async () => {
prisma.ticket.findUnique.mockResolvedValue({
id: 'ticket-1',
ownerId: 'owner-1',
chainTicketId: 7n,
event: {
organizationId: 'org-1',
organization: { stellarAccount: 'GORG' },
maxResaleMultiplierBps: 20_000,
},
ticketType: { price: 1_000n },
});
// Both transactions race past the pre-check; the DB partial unique
// index rejects the loser with a P2002 on the active-listing index.
prisma.$transaction.mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError(
'Unique constraint failed',
{
code: 'P2002',
clientVersion: 'test',
meta: { target: ['ticketId', 'ResaleListing_ticketId_active_key'] },
},
),
);

await expect(
service.confirmListForResale('owner-1', 'ticket-1', '1200', 'signed-xdr'),
).rejects.toBeInstanceOf(ConflictException);
});

it('enforces soft limit on active resale listings per user (409 Conflict)', async () => {
prisma.resaleListing.count.mockResolvedValue(5);

Expand Down
Loading