perf(db): add missing indexes, fix N+1 batch delete, add pagination limits

- Add indexes on Resource(blueprintId, roleId), DemandRequirement(roleId),
  Assignment(roleId) — commonly filtered FK columns that were missing indexes
- Replace N+1 batch delete pattern (2N queries) with findAllocationEntries()
  that does 2 total queries via findMany({ id: { in: ids } })
- Add take/skip pagination with default limit of 500 to listDemands and
  listAssignments to prevent unbounded result sets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 08:09:39 +02:00
parent 110e4ff1aa
commit c098cedf06
7 changed files with 264 additions and 158 deletions
+58 -35
View File
@@ -5,7 +5,11 @@ import { anonymizeResource, getAnonymizationDirectory } from "../../lib/anonymiz
import { planningReadProcedure } from "../../trpc.js";
import { buildResourceAvailabilitySummary, buildResourceAvailabilityView } from "./availability.js";
import { ASSIGNMENT_INCLUDE, DEMAND_INCLUDE } from "./shared.js";
import { getDemandRequirementByIdOrThrow, loadAllocationReadModel, resolveAssignmentBySelection } from "./support.js";
import {
getDemandRequirementByIdOrThrow,
loadAllocationReadModel,
resolveAssignmentBySelection,
} from "./support.js";
export const allocationReadProcedures = {
list: planningReadProcedure
@@ -37,6 +41,8 @@ export const allocationReadProcedures = {
projectId: z.string().optional(),
status: z.nativeEnum(AllocationStatus).optional(),
roleId: z.string().optional(),
take: z.number().int().min(1).max(1000).default(500),
skip: z.number().int().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
@@ -48,6 +54,8 @@ export const allocationReadProcedures = {
},
include: DEMAND_INCLUDE,
orderBy: { startDate: "asc" },
take: input.take,
skip: input.skip,
});
const directory = await getAnonymizationDirectory(ctx.db);
if (!directory) {
@@ -55,11 +63,11 @@ export const allocationReadProcedures = {
}
return demands.map((demand) => ({
...demand,
assignments: demand.assignments.map((assignment) => (
assignments: demand.assignments.map((assignment) =>
assignment.resource
? { ...assignment, resource: anonymizeResource(assignment.resource, directory) }
: assignment
)),
: assignment,
),
}));
}),
@@ -70,6 +78,8 @@ export const allocationReadProcedures = {
resourceId: z.string().optional(),
status: z.nativeEnum(AllocationStatus).optional(),
demandRequirementId: z.string().optional(),
take: z.number().int().min(1).max(1000).default(500),
skip: z.number().int().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
@@ -82,16 +92,18 @@ export const allocationReadProcedures = {
},
include: ASSIGNMENT_INCLUDE,
orderBy: { startDate: "asc" },
take: input.take,
skip: input.skip,
});
const directory = await getAnonymizationDirectory(ctx.db);
if (!directory) {
return assignments;
}
return assignments.map((assignment) => (
return assignments.map((assignment) =>
assignment.resource
? { ...assignment, resource: anonymizeResource(assignment.resource, directory) }
: assignment
));
: assignment,
);
}),
getAssignmentById: planningReadProcedure
@@ -115,15 +127,17 @@ export const allocationReadProcedures = {
}),
resolveAssignment: planningReadProcedure
.input(z.object({
assignmentId: z.string().optional(),
resourceId: z.string().optional(),
projectId: z.string().optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
selectionMode: z.enum(["WINDOW", "EXACT_START"]).default("EXACT_START"),
excludeCancelled: z.boolean().default(false),
}))
.input(
z.object({
assignmentId: z.string().optional(),
resourceId: z.string().optional(),
projectId: z.string().optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
selectionMode: z.enum(["WINDOW", "EXACT_START"]).default("EXACT_START"),
excludeCancelled: z.boolean().default(false),
}),
)
.query(async ({ ctx, input }) => resolveAssignmentBySelection(ctx.db, input)),
getDemandRequirementById: planningReadProcedure
@@ -131,33 +145,42 @@ export const allocationReadProcedures = {
.query(async ({ ctx, input }) => getDemandRequirementByIdOrThrow(ctx.db, input.id)),
checkResourceAvailability: planningReadProcedure
.input(z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0.5).max(24).default(8),
}))
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0.5).max(24).default(8),
}),
)
.query(async ({ ctx, input }) => {
const { vacations: _vacations, ...availability } = await buildResourceAvailabilityView(ctx.db, input);
const { vacations: _vacations, ...availability } = await buildResourceAvailabilityView(
ctx.db,
input,
);
return availability;
}),
getResourceAvailabilityView: planningReadProcedure
.input(z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0.5).max(24).default(8),
}))
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0.5).max(24).default(8),
}),
)
.query(async ({ ctx, input }) => buildResourceAvailabilityView(ctx.db, input)),
getResourceAvailabilitySummary: planningReadProcedure
.input(z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0.5).max(24).default(8),
}))
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0.5).max(24).default(8),
}),
)
.query(async ({ ctx, input }) => {
const availability = await buildResourceAvailabilityView(ctx.db, input);
return buildResourceAvailabilitySummary(availability, input);