Skip to content

Commit 53e5ab5

Browse files
feat: Implement quota system (#19)
- Added `Quota` domain model and database schema (Liquibase + Prisma). - Implemented `QuotaRepository` with `QuotaDbRepository`. - Extended `GroupRepository`, `SpaceRepository`, `WorkflowTemplateRepository`, `GroupMembershipRepository`, and `WorkflowRepository` with count methods. - Implemented `QuotaService`
1 parent 84d3fff commit 53e5ab5

25 files changed

Lines changed: 732 additions & 5 deletions

app/domain/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ export * from "./events"
2121
export * from "./workflow-tasks"
2222
export * from "./http"
2323
export * from "./occ"
24+
export * from "./quota"

app/domain/src/quota.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {PrefixUnion} from "@utils"
2+
import {v4 as uuid} from "uuid"
3+
import {isObject, isUUIDv4} from "@utils/validation"
4+
import {Either, isLeft, left, right} from "fp-ts/Either"
5+
6+
const quotasMap = {
7+
GROUP: ["MAX_ENTITIES_PER_GROUP"],
8+
TEMPLATE: ["MAX_CONCURRENT_WORKFLOWS"],
9+
USER: ["MAX_ROLES_PER_USER"],
10+
GLOBAL: ["MAX_GROUPS", "MAX_SPACES"],
11+
SPACE: ["MAX_TEMPLATES"]
12+
} as const
13+
14+
export type QuotaScope = keyof typeof quotasMap
15+
// [number] extract the string literals from the arrays
16+
export type QuotaMetric = (typeof quotasMap)[QuotaScope][number]
17+
18+
type QuotasMap = typeof quotasMap
19+
20+
export type QuotaIdentifier = {
21+
[K in keyof QuotasMap]: {
22+
scope: K
23+
metric: QuotasMap[K][number]
24+
}
25+
}[keyof QuotasMap]
26+
27+
export type Quota = QuotaIdentifier & {
28+
readonly id: string
29+
readonly limit: number
30+
readonly createdAt: Date
31+
readonly updatedAt: Date
32+
}
33+
34+
export type QuotaValidationError = PrefixUnion<
35+
"quota",
36+
"invalid_id" | "malformed_quota" | "invalid_scope" | "invalid_metric" | "invalid_limit"
37+
>
38+
39+
function isQuotaScope(val: string): val is QuotaScope {
40+
return val in quotasMap
41+
}
42+
43+
function isQuotaMetric(val: string): val is QuotaMetric {
44+
return Object.values(quotasMap)
45+
.flat()
46+
.map(m => m.toString())
47+
.includes(val)
48+
}
49+
50+
function validateMetricForScope(metric: QuotaMetric, scope: QuotaScope): Either<QuotaValidationError, QuotaIdentifier> {
51+
const allowedMetrics = quotasMap[scope].map(m => m.toString())
52+
if (!allowedMetrics.includes(metric)) return left("quota_invalid_metric")
53+
return right({scope, metric} as QuotaIdentifier)
54+
}
55+
56+
export class QuotaFactory {
57+
static validate(data: unknown): Either<QuotaValidationError, Quota> {
58+
if (!isObject(data)) return left("quota_malformed_quota")
59+
60+
if (typeof data.id !== "string") return left("quota_malformed_quota")
61+
if (!isUUIDv4(data.id)) return left("quota_invalid_id")
62+
if (typeof data.scope !== "string" || !isQuotaScope(data.scope)) return left("quota_invalid_scope")
63+
if (typeof data.metric !== "string" || !isQuotaMetric(data.metric)) return left("quota_invalid_metric")
64+
if (typeof data.limit !== "number" || !Number.isInteger(data.limit) || data.limit < 0)
65+
return left("quota_invalid_limit")
66+
if (!(data.createdAt instanceof Date)) return left("quota_malformed_quota")
67+
if (!(data.updatedAt instanceof Date)) return left("quota_malformed_quota")
68+
69+
const metric = data.metric
70+
const scope = data.scope
71+
72+
const metricIdentifier = validateMetricForScope(metric, scope)
73+
74+
if (isLeft(metricIdentifier)) return metricIdentifier
75+
76+
return right({
77+
id: data.id,
78+
...metricIdentifier.right,
79+
limit: data.limit,
80+
createdAt: data.createdAt,
81+
updatedAt: data.updatedAt
82+
})
83+
}
84+
85+
static newQuota(identifier: QuotaIdentifier, limit: number): Either<QuotaValidationError, Quota> {
86+
const now = new Date()
87+
88+
return this.validate({
89+
id: uuid(),
90+
...identifier,
91+
limit,
92+
updatedAt: now,
93+
createdAt: now
94+
})
95+
}
96+
}

app/domain/test/organization-admin.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ describe("OrganizationAdminFactory", () => {
1515
const result = OrganizationAdminFactory.newOrganizationAdmin(adminData)
1616

1717
// Expect: Success with valid organization admin
18-
expect(result).toBeRight()
1918
expect(result).toBeRightOf(
2019
expect.objectContaining({
2120
email: "admin@example.com",

app/external/src/database/group-membership.repository.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,26 @@ export class GroupMembershipDbRepository implements GroupMembershipRepository {
120120
)
121121
}
122122

123+
countUserMembersByGroupId(groupId: string): TaskEither<UnknownError, number> {
124+
return TE.tryCatch(
125+
() => this.dbClient.groupMembership.count({where: {groupId}}),
126+
error => {
127+
Logger.error("Error counting user memberships", error)
128+
return "unknown_error"
129+
}
130+
)
131+
}
132+
133+
countAgentMembersByGroupId(groupId: string): TaskEither<UnknownError, number> {
134+
return TE.tryCatch(
135+
() => this.dbClient.agentGroupMembership.count({where: {groupId}}),
136+
error => {
137+
Logger.error("Error counting agent memberships", error)
138+
return "unknown_error"
139+
}
140+
)
141+
}
142+
123143
private getObjectTask(): (
124144
data: GetGroupWithMembershipRepo
125145
) => TaskEither<GetGroupRepoError, GroupWithMemberships | null> {

app/external/src/database/group.repository.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {Group, GroupWithEntitiesCount, ListGroupsFilter} from "@domain"
22
import {isPrismaForeignKeyConstraintError, isPrismaUniqueConstraintError} from "@external/database/errors"
33
import {Injectable, Logger} from "@nestjs/common"
44
import {Prisma, Group as PrismaGroup} from "@prisma/client"
5+
import {UnknownError} from "@services/error"
56
import {
67
CreateGroupRepoError,
78
CreateGroupWithMembershipAndUpdateUserRepo,
@@ -204,6 +205,16 @@ export class GroupDbRepository implements GroupRepository {
204205
)
205206
}
206207

208+
countGroups(): TaskEither<UnknownError, number> {
209+
return TE.tryCatch(
210+
() => this.dbClient.group.count(),
211+
error => {
212+
Logger.error("Error counting groups", error)
213+
return "unknown_error"
214+
}
215+
)
216+
}
217+
207218
private getGroupsByAgentIdTask(): (agentId: string) => TaskEither<GetGroupRepoError, PrismaGroupWithCount[]> {
208219
return agentId =>
209220
TE.tryCatchK(

app/external/src/database/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ export * from "./pkce-session.repository"
1414
export * from "./task.repository"
1515
export * from "./refresh-token.repository"
1616
export * from "./health.repository"
17+
export * from "./quota.repository"
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {Quota, QuotaFactory, QuotaIdentifier, Versioned} from "@domain"
2+
import {Injectable, Logger} from "@nestjs/common"
3+
import {QuotaCreateError, QuotaGetError, QuotaRepository, QuotaUpdateError} from "@services"
4+
import * as TE from "fp-ts/lib/TaskEither"
5+
import {pipe} from "fp-ts/lib/function"
6+
import {POSTGRES_BIGINT_LOWER_BOUND} from "./constants"
7+
import {DatabaseClient} from "./database-client"
8+
import {isPrismaUniqueConstraintError} from "./errors"
9+
10+
@Injectable()
11+
export class QuotaDbRepository implements QuotaRepository {
12+
constructor(private readonly dbClient: DatabaseClient) {}
13+
14+
getQuota(identifier: QuotaIdentifier): TE.TaskEither<QuotaGetError, Versioned<Quota>> {
15+
return pipe(
16+
TE.tryCatch(
17+
() =>
18+
this.dbClient.quota.findUnique({
19+
where: {
20+
scope_metric: {
21+
scope: identifier.scope,
22+
metric: identifier.metric
23+
}
24+
}
25+
}),
26+
error => {
27+
Logger.error("Error retrieving quota", error)
28+
return "quota_unknown_error" as const
29+
}
30+
),
31+
TE.chainW(quota => {
32+
if (!quota) return TE.left("quota_not_found" as const)
33+
return pipe(
34+
TE.fromEither(QuotaFactory.validate(quota)),
35+
TE.map(validQuota => ({...validQuota, occ: quota.occ}))
36+
)
37+
})
38+
)
39+
}
40+
41+
createQuota(quota: Quota): TE.TaskEither<QuotaCreateError, Versioned<Quota>> {
42+
return pipe(
43+
TE.tryCatch(
44+
() =>
45+
this.dbClient.quota.create({
46+
data: {
47+
id: quota.id,
48+
scope: quota.scope,
49+
metric: quota.metric,
50+
limit: quota.limit,
51+
createdAt: quota.createdAt,
52+
updatedAt: quota.updatedAt,
53+
occ: POSTGRES_BIGINT_LOWER_BOUND
54+
}
55+
}),
56+
error => {
57+
if (isPrismaUniqueConstraintError(error, ["scope", "metric"])) return "quota_already_exists" as const
58+
Logger.error("Error creating quota", error)
59+
return "quota_unknown_error" as const
60+
}
61+
),
62+
TE.chainW(createdQuota =>
63+
pipe(
64+
TE.fromEither(QuotaFactory.validate(createdQuota)),
65+
TE.map(validQuota => ({...validQuota, occ: createdQuota.occ}))
66+
)
67+
)
68+
)
69+
}
70+
71+
updateQuota(quota: Quota, occCheck: bigint): TE.TaskEither<QuotaUpdateError, Versioned<Quota>> {
72+
return pipe(
73+
TE.tryCatch(
74+
async () => {
75+
return await this.dbClient.$transaction(async tx => {
76+
const updatedQuotas = await tx.quota.updateManyAndReturn({
77+
where: {
78+
scope: quota.scope,
79+
metric: quota.metric,
80+
occ: occCheck
81+
},
82+
data: {
83+
limit: quota.limit,
84+
createdAt: quota.createdAt,
85+
updatedAt: quota.updatedAt,
86+
occ: {increment: 1}
87+
}
88+
})
89+
90+
if (updatedQuotas.length === 0) {
91+
// Check if it failed due to OCC or Not Found
92+
const existing = await tx.quota.findUnique({
93+
where: {
94+
scope_metric: {
95+
scope: quota.scope,
96+
metric: quota.metric
97+
}
98+
}
99+
})
100+
101+
if (!existing) return "quota_not_found"
102+
if (existing.occ !== occCheck) return "quota_concurrent_modification_error"
103+
Logger.error("Error updating quota, quota exists and with the same occ", {existing, occCheck})
104+
return "quota_unknown_error"
105+
}
106+
107+
const item = updatedQuotas[0]
108+
109+
if (!item) {
110+
Logger.error("Error updating quota, no item returned", {updatedQuotas})
111+
return "quota_unknown_error"
112+
}
113+
114+
return item
115+
})
116+
},
117+
error => {
118+
Logger.error("Error updating quota", error)
119+
return "quota_unknown_error" as const
120+
}
121+
),
122+
TE.chainW(result => {
123+
if (typeof result === "string") return TE.left(result)
124+
return pipe(
125+
TE.fromEither(QuotaFactory.validate(result)),
126+
TE.map(validQuota => ({...validQuota, occ: result.occ}))
127+
)
128+
})
129+
)
130+
}
131+
}

app/external/src/database/space.repository.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,16 @@ export class SpaceDbRepository implements SpaceRepository {
150150
)
151151
}
152152

153+
countSpaces(): TaskEither<"unknown_error", number> {
154+
return TE.tryCatch(
155+
() => this.dbClient.space.count(),
156+
error => {
157+
Logger.error("Error counting spaces", error)
158+
return "unknown_error"
159+
}
160+
)
161+
}
162+
153163
private buildWhereClauseGetSpaceTask(request: GetSpaceTaskRequest): Prisma.SpaceWhereUniqueInput {
154164
return request.identifier.type === "id" ? {id: request.identifier.value} : {name: request.identifier.value}
155165
}

app/external/src/database/workflow-template.repository.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,16 @@ export class WorkflowTemplateDbRepository implements WorkflowTemplateRepository
233233
)
234234
}
235235

236+
countWorkflowTemplatesBySpaceId(spaceId: string): TaskEither<UnknownError, number> {
237+
return TE.tryCatch(
238+
() => this.dbClient.workflowTemplate.count({where: {spaceId}}),
239+
error => {
240+
Logger.error("Error counting workflow templates", error)
241+
return "unknown_error"
242+
}
243+
)
244+
}
245+
236246
private atomicUpdateAndCreateTask(): (data: {
237247
existingTemplate: Versioned<WorkflowTemplate>
238248
newTemplate: WorkflowTemplate

app/external/src/database/workflow.repository.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import {
2020
WorkflowRepository,
2121
WorkflowUpdateError,
2222
ListWorkflowsRequest,
23-
ListWorkflowsResponse
23+
ListWorkflowsResponse,
24+
UnknownError
2425
} from "@services"
2526
import {TaskEither} from "fp-ts/TaskEither"
2627
import * as E from "fp-ts/Either"
@@ -143,6 +144,24 @@ export class WorkflowDbRepository implements WorkflowRepository {
143144
)
144145
}
145146

147+
countActiveWorkflowsByTemplateId(templateId: string): TaskEither<UnknownError, number> {
148+
return TE.tryCatch(
149+
() =>
150+
this.dbClient.workflow.count({
151+
where: {
152+
workflowTemplateId: templateId,
153+
status: {
154+
notIn: WORKFLOW_TERMINAL_STATUSES
155+
}
156+
}
157+
}),
158+
error => {
159+
Logger.error("Error counting active workflows", error)
160+
return "unknown_error"
161+
}
162+
)
163+
}
164+
146165
private updateWorkflowTask<T extends PrismaWorkflowDecoratorSelector>(): (data: {
147166
workflowId: string
148167
data: Omit<Prisma.WorkflowUpdateInput, "id" | "occ">

0 commit comments

Comments
 (0)