dorfteich/apps/api/src/quotas/quota.service.ts
Claude Fable 5 64928f0ac0
All checks were successful
CD / Build and push images (push) Successful in 1m46s
CI / Lint, typecheck, test (push) Successful in 1m17s
CI / Auth e2e pack (push) Successful in 1m39s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m6s
CD / Promote to Int (push) Successful in 10s
Quota foundation: overrides, resolution, race-safe consumption (#22)
- quota_overrides + pond_usage models (BigInt values, unique per
  subject+key); migration 20260705185146_quotas
- instance-default quota keys in the settings registry (editors 5,
  readers 50, additional ponds 0, storage 1 GiB, max file 25 MiB)
- QuotaService: getEffective with pond → user → instance resolution
  (zero counts as a value, not a gap); assertCanCreateSharedPond and
  checkAndConsume serialize via pg_advisory_xact_lock inside the guarded
  write's transaction; release never drops below zero
- pond creation enforces additional_ponds (personal ponds don't count);
  quota errors carry code quota_exceeded + {quotaKey, limit}, localized
- seed grants fixtures an additional_ponds override (default is 0)
- table-driven resolution tests, parallel-consumption test, e2e for the
  pond-creation limit

Closes #22

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpQz6ypHJsLfMf4S6fyQEB
2026-07-05 20:55:59 +02:00

106 lines
4.2 KiB
TypeScript

import { ForbiddenException, Injectable } from '@nestjs/common';
import { QuotaKey } from '@dorfteich/shared';
import { Prisma, QuotaSubjectType } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { InstanceSettingKey, InstanceSettingsService } from '../settings/instance-settings.service';
const SETTING_FOR_KEY: Record<QuotaKey, InstanceSettingKey> = {
editors_per_pond: 'quota.editorsPerPond',
readers_per_pond: 'quota.readersPerPond',
additional_ponds: 'quota.additionalPonds',
storage_bytes: 'quota.storageBytes',
max_file_bytes: 'quota.maxFileBytes',
};
/** 403 with the key and the limit so the client can name both (i18n). */
export function quotaExceeded(quotaKey: QuotaKey, limit: number): ForbiddenException {
return new ForbiddenException({ code: 'quota_exceeded', details: { quotaKey, limit } });
}
/**
* Quota resolution and race-safe consumption (issue #22, ADR 0011).
* Every quota question goes through `getEffective`; every guarded write
* consumes inside the same transaction as the write it protects.
*/
@Injectable()
export class QuotaService {
constructor(
private readonly prisma: PrismaService,
private readonly settings: InstanceSettingsService,
) {}
/** Pond override → user override → instance default; most specific wins. */
async getEffective(key: QuotaKey, scope: { userId?: string; pondId?: string }): Promise<number> {
if (scope.pondId) {
const pondValue = await this.findOverride('POND', scope.pondId, key);
if (pondValue !== null) return pondValue;
}
if (scope.userId) {
const userValue = await this.findOverride('USER', scope.userId, key);
if (userValue !== null) return userValue;
}
return (await this.settings.get(SETTING_FOR_KEY[key])) as number;
}
/**
* Guards creating another shared pond. Must run inside the transaction
* that creates the pond; the advisory lock serializes competing
* creations by the same user so the count cannot race.
*/
async assertCanCreateSharedPond(tx: Prisma.TransactionClient, userId: string): Promise<void> {
// ::text because Prisma cannot deserialize the function's void result.
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtext(${`quota:additional_ponds:${userId}`}))::text`;
const limit = await this.getEffective('additional_ponds', { userId });
const owned = await tx.pond.count({
// The personal pond never counts against `additional_ponds`.
where: { ownerId: userId, type: 'SHARED', deletedAt: null },
});
if (owned >= limit) throw quotaExceeded('additional_ponds', limit);
}
/**
* Consumes storage budget for a pond, atomically against the effective
* limit. Throws `quota_exceeded` without changing the counter when the
* delta would not fit.
*/
async checkAndConsume(pondId: string, ownerUserId: string, deltaBytes: number): Promise<void> {
const limit = await this.getEffective('storage_bytes', { userId: ownerUserId, pondId });
await this.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtext(${`quota:storage:${pondId}`}))::text`;
const usage = await tx.pondUsage.upsert({
where: { pondId },
create: { pondId },
update: {},
});
if (Number(usage.storageBytesUsed) + deltaBytes > limit) {
throw quotaExceeded('storage_bytes', limit);
}
await tx.pondUsage.update({
where: { pondId },
data: { storageBytesUsed: { increment: deltaBytes } },
});
});
}
/** Returns storage budget (file deleted/purged); the counter never goes below zero. */
async release(pondId: string, deltaBytes: number): Promise<void> {
await this.prisma.$executeRaw`
UPDATE pond_usage
SET storage_bytes_used = GREATEST(storage_bytes_used - ${deltaBytes}, 0),
updated_at = now()
WHERE pond_id = ${pondId}`;
}
private async findOverride(
subjectType: QuotaSubjectType,
subjectId: string,
quotaKey: QuotaKey,
): Promise<number | null> {
const row = await this.prisma.quotaOverride.findUnique({
where: { subjectType_subjectId_quotaKey: { subjectType, subjectId, quotaKey } },
});
return row === null ? null : Number(row.value);
}
}