Quota foundation: overrides, resolution, race-safe consumption (#22)
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
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
This commit is contained in:
parent
f0850eecd3
commit
64928f0ac0
@ -0,0 +1,31 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "QuotaSubjectType" AS ENUM ('USER', 'POND');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "quota_overrides" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"subject_type" "QuotaSubjectType" NOT NULL,
|
||||||
|
"subject_id" TEXT NOT NULL,
|
||||||
|
"quota_key" TEXT NOT NULL,
|
||||||
|
"value" BIGINT NOT NULL,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "quota_overrides_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "pond_usage" (
|
||||||
|
"pond_id" TEXT NOT NULL,
|
||||||
|
"storage_bytes_used" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"editor_count" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"reader_count" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "pond_usage_pkey" PRIMARY KEY ("pond_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "quota_overrides_subject_type_subject_id_quota_key_key" ON "quota_overrides"("subject_type", "subject_id", "quota_key");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pond_usage" ADD CONSTRAINT "pond_usage_pond_id_fkey" FOREIGN KEY ("pond_id") REFERENCES "ponds"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -73,11 +73,47 @@ model Pond {
|
|||||||
deletedBy String? @map("deleted_by")
|
deletedBy String? @map("deleted_by")
|
||||||
|
|
||||||
owner User @relation(fields: [ownerId], references: [id])
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
|
usage PondUsage?
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@map("ponds")
|
@@map("ponds")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum QuotaSubjectType {
|
||||||
|
USER
|
||||||
|
POND
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-user/per-pond quota values (ADR 0011). Resolution: pond override →
|
||||||
|
/// user override → instance default (QuotaService). `value` is BigInt so
|
||||||
|
/// storage limits beyond 2 GiB fit.
|
||||||
|
model QuotaOverride {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
subjectType QuotaSubjectType @map("subject_type")
|
||||||
|
subjectId String @map("subject_id")
|
||||||
|
quotaKey String @map("quota_key")
|
||||||
|
value BigInt
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@unique([subjectType, subjectId, quotaKey])
|
||||||
|
@@map("quota_overrides")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached usage counters per pond, updated transactionally with the
|
||||||
|
/// guarded writes (uploads land in M2 #27, membership in M5); reconciled
|
||||||
|
/// nightly by a maintenance job (operations.md).
|
||||||
|
model PondUsage {
|
||||||
|
pondId String @id @map("pond_id")
|
||||||
|
storageBytesUsed BigInt @default(0) @map("storage_bytes_used")
|
||||||
|
editorCount Int @default(0) @map("editor_count")
|
||||||
|
readerCount Int @default(0) @map("reader_count")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
pond Pond @relation(fields: [pondId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("pond_usage")
|
||||||
|
}
|
||||||
|
|
||||||
/// One row per login method. `provider` is "password" today and
|
/// One row per login method. `provider` is "password" today and
|
||||||
/// "oidc:<issuer>" later; `credential` holds the Argon2id hash for
|
/// "oidc:<issuer>" later; `credential` holds the Argon2id hash for
|
||||||
/// password identities.
|
/// password identities.
|
||||||
|
|||||||
@ -91,6 +91,24 @@ async function upsertFixtureUser(fixture: FixtureUser): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// The instance default for additional_ponds is 0 (ADR 0011) — give
|
||||||
|
// the fixtures headroom so pond flows are exercisable in dev/e2e.
|
||||||
|
await prisma.quotaOverride.upsert({
|
||||||
|
where: {
|
||||||
|
subjectType_subjectId_quotaKey: {
|
||||||
|
subjectType: 'USER',
|
||||||
|
subjectId: user.id,
|
||||||
|
quotaKey: 'additional_ponds',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
subjectType: 'USER',
|
||||||
|
subjectId: user.id,
|
||||||
|
quotaKey: 'additional_ponds',
|
||||||
|
value: 100,
|
||||||
|
},
|
||||||
|
update: { value: 100 },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -48,6 +48,16 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
|
|||||||
});
|
});
|
||||||
const verifyToken = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600);
|
const verifyToken = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600);
|
||||||
await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204);
|
await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204);
|
||||||
|
// additional_ponds defaults to 0 (ADR 0011) — the shared-pond tests
|
||||||
|
// need headroom; the outsider stays at the default for the quota test.
|
||||||
|
await prisma.quotaOverride.create({
|
||||||
|
data: {
|
||||||
|
subjectType: 'USER',
|
||||||
|
subjectId: ownerUser.id,
|
||||||
|
quotaKey: 'additional_ponds',
|
||||||
|
value: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
ownerCookie = await loginOf(owner.username);
|
ownerCookie = await loginOf(owner.username);
|
||||||
|
|
||||||
// The outsider doubles as Site Admin in the trash/restore tests.
|
// The outsider doubles as Site Admin in the trash/restore tests.
|
||||||
@ -63,6 +73,13 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { username: { contains: suffix } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await prisma.quotaOverride.deleteMany({
|
||||||
|
where: { subjectId: { in: users.map((u) => u.id) } },
|
||||||
|
});
|
||||||
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
||||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
@ -144,6 +161,39 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
|
|||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('enforces the additional_ponds quota (default 0, override wins)', async () => {
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/ponds')
|
||||||
|
.set('Cookie', outsiderCookie)
|
||||||
|
.send({ name: `Quotateich ${suffix}` })
|
||||||
|
.expect(403);
|
||||||
|
expect(res.body.code).toBe('quota_exceeded');
|
||||||
|
expect(res.body.details).toMatchObject({ quotaKey: 'additional_ponds', limit: 0 });
|
||||||
|
|
||||||
|
const outsiderUser = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { username: outsider.username },
|
||||||
|
});
|
||||||
|
await prisma.quotaOverride.create({
|
||||||
|
data: {
|
||||||
|
subjectType: 'USER',
|
||||||
|
subjectId: outsiderUser.id,
|
||||||
|
quotaKey: 'additional_ponds',
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/ponds')
|
||||||
|
.set('Cookie', outsiderCookie)
|
||||||
|
.send({ name: `Quotateich ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
const second = await api()
|
||||||
|
.post('/api/v1/ponds')
|
||||||
|
.set('Cookie', outsiderCookie)
|
||||||
|
.send({ name: `Quotateich zwei ${suffix}` })
|
||||||
|
.expect(403);
|
||||||
|
expect(second.body.details.limit).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('soft-deletes a shared pond; Site Admin sees trash and restores', async () => {
|
it('soft-deletes a shared pond; Site Admin sees trash and restores', async () => {
|
||||||
const created = await api()
|
const created = await api()
|
||||||
.post('/api/v1/ponds')
|
.post('/api/v1/ponds')
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { QuotasModule } from '../quotas/quotas.module';
|
||||||
|
|
||||||
import { InterimAccessService } from './interim-access.service';
|
import { InterimAccessService } from './interim-access.service';
|
||||||
import { PondsController } from './ponds.controller';
|
import { PondsController } from './ponds.controller';
|
||||||
import { PondsService } from './ponds.service';
|
import { PondsService } from './ponds.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [QuotasModule],
|
||||||
controllers: [PondsController],
|
controllers: [PondsController],
|
||||||
providers: [PondsService, InterimAccessService],
|
providers: [PondsService, InterimAccessService],
|
||||||
exports: [PondsService, InterimAccessService],
|
exports: [PondsService, InterimAccessService],
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { Pond, User } from '@prisma/client';
|
|||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { QuotaService } from '../quotas/quota.service';
|
||||||
import { InterimAccessService } from './interim-access.service';
|
import { InterimAccessService } from './interim-access.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -17,6 +18,7 @@ export class PondsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly access: InterimAccessService,
|
private readonly access: InterimAccessService,
|
||||||
|
private readonly quotas: QuotaService,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(PondsService.name);
|
this.logger.setContext(PondsService.name);
|
||||||
@ -60,15 +62,21 @@ export class PondsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createShared(owner: User, input: CreatePondInput): Promise<PondView> {
|
async createShared(owner: User, input: CreatePondInput): Promise<PondView> {
|
||||||
const pond = await this.prisma.pond.create({
|
const slug = await this.generateUniqueSlug(input.name, owner.username);
|
||||||
|
// Quota check and create share one transaction — the advisory lock in
|
||||||
|
// the check makes concurrent creations by the same user race-safe.
|
||||||
|
const pond = await this.prisma.$transaction(async (tx) => {
|
||||||
|
await this.quotas.assertCanCreateSharedPond(tx, owner.id);
|
||||||
|
return tx.pond.create({
|
||||||
data: {
|
data: {
|
||||||
slug: await this.generateUniqueSlug(input.name, owner.username),
|
slug,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
type: 'SHARED',
|
type: 'SHARED',
|
||||||
ownerId: owner.id,
|
ownerId: owner.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond created');
|
this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond created');
|
||||||
return this.viewOf(pond);
|
return this.viewOf(pond);
|
||||||
}
|
}
|
||||||
|
|||||||
130
apps/api/src/quotas/quota.service.db.test.ts
Normal file
130
apps/api/src/quotas/quota.service.db.test.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTestApp } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { QuotaService } from './quota.service';
|
||||||
|
|
||||||
|
describe.skipIf(!hasTestDb)('QuotaService (db, issue #22)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let quotas: QuotaService;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
let userId: string;
|
||||||
|
let pondId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
app = await createTestApp();
|
||||||
|
quotas = app.get(QuotaService);
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: `quinn-${suffix}`,
|
||||||
|
email: `quinn-${suffix}@example.org`,
|
||||||
|
displayName: 'Quinn Quota',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userId = user.id;
|
||||||
|
const pond = await prisma.pond.create({
|
||||||
|
data: { slug: `quota-${suffix}`, name: 'Quota Pond', type: 'PERSONAL', ownerId: userId },
|
||||||
|
});
|
||||||
|
pondId = pond.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: [userId, pondId] } } });
|
||||||
|
await prisma.pond.deleteMany({ where: { ownerId: userId } });
|
||||||
|
await prisma.user.delete({ where: { id: userId } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setOverride(
|
||||||
|
subjectType: 'USER' | 'POND',
|
||||||
|
subjectId: string,
|
||||||
|
value: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.quotaOverride.upsert({
|
||||||
|
where: {
|
||||||
|
subjectType_subjectId_quotaKey: { subjectType, subjectId, quotaKey: 'storage_bytes' },
|
||||||
|
},
|
||||||
|
create: { subjectType, subjectId, quotaKey: 'storage_bytes', value },
|
||||||
|
update: { value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearOverrides(): Promise<void> {
|
||||||
|
await prisma.quotaOverride.deleteMany({
|
||||||
|
where: { subjectId: { in: [userId, pondId] }, quotaKey: 'storage_bytes' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------- resolution ladder
|
||||||
|
const GIB = 1024 * 1024 * 1024;
|
||||||
|
const cases: Array<{
|
||||||
|
name: string;
|
||||||
|
userOverride: number | null;
|
||||||
|
pondOverride: number | null;
|
||||||
|
expected: number;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'no overrides → instance default',
|
||||||
|
userOverride: null,
|
||||||
|
pondOverride: null,
|
||||||
|
expected: GIB,
|
||||||
|
},
|
||||||
|
{ name: 'user override only', userOverride: 123, pondOverride: null, expected: 123 },
|
||||||
|
{ name: 'pond override only', userOverride: null, pondOverride: 456, expected: 456 },
|
||||||
|
{
|
||||||
|
name: 'pond override beats user override',
|
||||||
|
userOverride: 123,
|
||||||
|
pondOverride: 456,
|
||||||
|
expected: 456,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'zero is a valid override, not a gap',
|
||||||
|
userOverride: 123,
|
||||||
|
pondOverride: 0,
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const c of cases) {
|
||||||
|
it(`resolves: ${c.name}`, async () => {
|
||||||
|
await clearOverrides();
|
||||||
|
if (c.userOverride !== null) await setOverride('USER', userId, c.userOverride);
|
||||||
|
if (c.pondOverride !== null) await setOverride('POND', pondId, c.pondOverride);
|
||||||
|
expect(await quotas.getEffective('storage_bytes', { userId, pondId })).toBe(c.expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------- consumption
|
||||||
|
it('consumes storage transactionally and rejects what does not fit', async () => {
|
||||||
|
await clearOverrides();
|
||||||
|
await setOverride('POND', pondId, 1000);
|
||||||
|
await prisma.pondUsage.deleteMany({ where: { pondId } });
|
||||||
|
|
||||||
|
// Two parallel consumers want 600 each — exactly one may win.
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
quotas.checkAndConsume(pondId, userId, 600),
|
||||||
|
quotas.checkAndConsume(pondId, userId, 600),
|
||||||
|
]);
|
||||||
|
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
||||||
|
expect(fulfilled).toHaveLength(1);
|
||||||
|
|
||||||
|
const usage = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } });
|
||||||
|
expect(Number(usage.storageBytesUsed)).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases storage without dropping below zero', async () => {
|
||||||
|
await quotas.release(pondId, 400);
|
||||||
|
let usage = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } });
|
||||||
|
expect(Number(usage.storageBytesUsed)).toBe(200);
|
||||||
|
|
||||||
|
await quotas.release(pondId, 9999);
|
||||||
|
usage = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } });
|
||||||
|
expect(Number(usage.storageBytesUsed)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
105
apps/api/src/quotas/quota.service.ts
Normal file
105
apps/api/src/quotas/quota.service.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/quotas/quotas.module.ts
Normal file
9
apps/api/src/quotas/quotas.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { QuotaService } from './quota.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [QuotaService],
|
||||||
|
exports: [QuotaService],
|
||||||
|
})
|
||||||
|
export class QuotasModule {}
|
||||||
@ -14,6 +14,21 @@ export const INSTANCE_SETTINGS = {
|
|||||||
'auth.registrationMode': z.enum(['open', 'closed']).default('open'),
|
'auth.registrationMode': z.enum(['open', 'closed']).default('open'),
|
||||||
'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'),
|
'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'),
|
||||||
'instance.defaultLocale': z.enum(['de', 'en']).default('en'),
|
'instance.defaultLocale': z.enum(['de', 'en']).default('en'),
|
||||||
|
// Instance-default quotas (ADR 0011); per-user/per-pond overrides live
|
||||||
|
// in quota_overrides and win over these (QuotaService, issue #22).
|
||||||
|
'quota.editorsPerPond': z.number().int().min(0).default(5),
|
||||||
|
'quota.readersPerPond': z.number().int().min(0).default(50),
|
||||||
|
'quota.additionalPonds': z.number().int().min(0).default(0),
|
||||||
|
'quota.storageBytes': z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.default(1024 * 1024 * 1024),
|
||||||
|
'quota.maxFileBytes': z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.default(25 * 1024 * 1024),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS;
|
export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS;
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
"csrf_origin_mismatch": "Die Anfrage kam von einer unerwarteten Herkunft.",
|
"csrf_origin_mismatch": "Die Anfrage kam von einer unerwarteten Herkunft.",
|
||||||
"cannot_revoke_current_session": "Beende deine aktuelle Sitzung über die Abmeldung.",
|
"cannot_revoke_current_session": "Beende deine aktuelle Sitzung über die Abmeldung.",
|
||||||
"personal_pond_undeletable": "Der persönliche Teich kann nicht gelöscht werden.",
|
"personal_pond_undeletable": "Der persönliche Teich kann nicht gelöscht werden.",
|
||||||
|
"quota_exceeded": "Das Kontingent ist erreicht (Limit: {{limit}}).",
|
||||||
"network": "Der Server war nicht erreichbar.",
|
"network": "Der Server war nicht erreichbar.",
|
||||||
"validation": {
|
"validation": {
|
||||||
"required": "Dieses Feld ist erforderlich.",
|
"required": "Dieses Feld ist erforderlich.",
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
"csrf_origin_mismatch": "The request came from an unexpected origin.",
|
"csrf_origin_mismatch": "The request came from an unexpected origin.",
|
||||||
"cannot_revoke_current_session": "Use sign-out to end your current session.",
|
"cannot_revoke_current_session": "Use sign-out to end your current session.",
|
||||||
"personal_pond_undeletable": "The personal pond cannot be deleted.",
|
"personal_pond_undeletable": "The personal pond cannot be deleted.",
|
||||||
|
"quota_exceeded": "The quota has been reached (limit: {{limit}}).",
|
||||||
"network": "The server could not be reached.",
|
"network": "The server could not be reached.",
|
||||||
"validation": {
|
"validation": {
|
||||||
"required": "This field is required.",
|
"required": "This field is required.",
|
||||||
|
|||||||
@ -4,3 +4,4 @@ export * from './env';
|
|||||||
export * from './health';
|
export * from './health';
|
||||||
export * from './i18n-tools';
|
export * from './i18n-tools';
|
||||||
export * from './ponds';
|
export * from './ponds';
|
||||||
|
export * from './quotas';
|
||||||
|
|||||||
14
packages/shared/src/quotas.ts
Normal file
14
packages/shared/src/quotas.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Quota dimensions (ADR 0011, issue #22). Each value resolves on the
|
||||||
|
* three-level ladder: pond override → user override → instance default —
|
||||||
|
* the most specific wins, mirroring the permission philosophy.
|
||||||
|
*/
|
||||||
|
export const QUOTA_KEYS = [
|
||||||
|
'editors_per_pond',
|
||||||
|
'readers_per_pond',
|
||||||
|
'additional_ponds',
|
||||||
|
'storage_bytes',
|
||||||
|
'max_file_bytes',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type QuotaKey = (typeof QUOTA_KEYS)[number];
|
||||||
Loading…
Reference in New Issue
Block a user