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_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
106 lines
4.2 KiB
TypeScript
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);
|
|
}
|
|
}
|