Skip to content

Commit 6f050ae

Browse files
committed
perf: optimize drawings endpoint with caching and lazy loading
- Add 5s in-memory cache for /drawings responses with automatic cleanup - Split Drawing/DrawingSummary types for efficient data fetching - Implement lazy loading of drawing data in DrawingCard component - Add configurable DRAWINGS_CACHE_TTL_MS and RATE_LIMIT_MAX_REQUESTS env vars - Prevent memory leaks with periodic cleanup of cache and rate limit maps - Add loading states and better UX for export operations - Improve JSON parsing with error handling for malformed stored data Benchmark results (100 drawings, cached): - Avg latency: 6.94ms (p50: 4ms, p97.5: 8ms) - Avg throughput: 668 req/s (peak: 1,023) - 3k requests in 5s with 0 errors Update .gitignore to exclude generated files, env files, and build artifacts
1 parent 971046d commit 6f050ae

6 files changed

Lines changed: 347 additions & 56 deletions

File tree

.gitignore

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,76 @@
1+
# Dependencies
12
frontend/node_modules
3+
backend/node_modules
4+
5+
# Database
6+
backend/prisma/*.db
7+
backend/prisma/dev.db
8+
9+
# Generated files
10+
backend/src/generated/
11+
12+
# Environment variables
13+
.env
14+
.env.local
15+
.env.production
16+
.env.staging
17+
18+
# Build outputs
19+
frontend/dist/
20+
frontend/build/
21+
backend/dist/
22+
23+
# Logs
24+
*.log
25+
logs/
26+
npm-debug.log*
27+
yarn-debug.log*
28+
yarn-error.log*
29+
30+
# Runtime data
31+
pids
32+
*.pid
33+
*.seed
34+
*.pid.lock
35+
36+
# Coverage directory used by tools like istanbul
37+
coverage/
38+
*.lcov
39+
40+
# Dependency directories
41+
jspm_packages/
42+
43+
# Optional npm cache directory
44+
.npm
45+
46+
# Optional REPL history
47+
.node_repl_history
48+
49+
# Output of 'npm pack'
50+
*.tgz
51+
52+
# Yarn Integrity file
53+
.yarn-integrity
54+
55+
# dotenv environment variables file
56+
.env.test
57+
58+
# IDE/Editor files
59+
.vscode/
60+
.idea/
61+
*.swp
62+
*.swo
63+
*~
64+
65+
# OS generated files
266
.DS_Store
3-
backend/prisma/*.db
67+
.DS_Store?
68+
._*
69+
.Spotlight-V100
70+
.Trashes
71+
ehthumbs.db
72+
Thumbs.db
73+
74+
# Temporary files
75+
*.tmp
76+
*.temp

backend/src/index.ts

Lines changed: 149 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import multer from "multer";
1111
import archiver from "archiver";
1212
import { z } from "zod";
1313
// @ts-ignore
14-
import { PrismaClient } from "./generated/client";
14+
import { PrismaClient, Prisma } from "./generated/client";
1515
import {
1616
sanitizeDrawingData,
1717
validateImportedDrawing,
@@ -112,6 +112,68 @@ const io = new Server(httpServer, {
112112
maxHttpBufferSize: 1e8, // 100 MB
113113
});
114114
const prisma = new PrismaClient();
115+
const parseJsonField = <T>(rawValue: string | null | undefined, fallback: T): T => {
116+
if (!rawValue) return fallback;
117+
try {
118+
return JSON.parse(rawValue) as T;
119+
} catch (error) {
120+
console.warn("Failed to parse JSON field", { error, valuePreview: rawValue.slice(0, 50) });
121+
return fallback;
122+
}
123+
};
124+
125+
const DRAWINGS_CACHE_TTL_MS = (() => {
126+
const parsed = Number(process.env.DRAWINGS_CACHE_TTL_MS);
127+
if (!Number.isFinite(parsed) || parsed <= 0) {
128+
return 5_000;
129+
}
130+
return parsed;
131+
})();
132+
type DrawingsCacheEntry = { body: Buffer; expiresAt: number };
133+
const drawingsCache = new Map<string, DrawingsCacheEntry>();
134+
135+
const buildDrawingsCacheKey = (keyParts: {
136+
searchTerm: string;
137+
collectionFilter: string;
138+
includeData: boolean;
139+
}) =>
140+
`${keyParts.searchTerm}|${keyParts.collectionFilter}|${
141+
keyParts.includeData ? "full" : "summary"
142+
}`;
143+
144+
const getCachedDrawingsBody = (key: string): Buffer | null => {
145+
const entry = drawingsCache.get(key);
146+
if (!entry) return null;
147+
if (Date.now() > entry.expiresAt) {
148+
drawingsCache.delete(key);
149+
return null;
150+
}
151+
return entry.body;
152+
};
153+
154+
const cacheDrawingsResponse = (key: string, payload: any): Buffer => {
155+
const body = Buffer.from(JSON.stringify(payload));
156+
drawingsCache.set(key, {
157+
body,
158+
expiresAt: Date.now() + DRAWINGS_CACHE_TTL_MS,
159+
});
160+
return body;
161+
};
162+
163+
const invalidateDrawingsCache = () => {
164+
drawingsCache.clear();
165+
};
166+
167+
// Cleanup cache every 60 seconds
168+
setInterval(() => {
169+
const now = Date.now();
170+
for (const [key, entry] of drawingsCache.entries()) {
171+
if (now > entry.expiresAt) {
172+
drawingsCache.delete(key);
173+
}
174+
}
175+
}, 60_000).unref(); // unref so it doesn't keep the process alive if everything else stops
176+
115177
const PORT = process.env.PORT || 8000;
116178

117179
// Multer setup for file uploads with streaming support
@@ -189,7 +251,24 @@ app.use((req, res, next) => {
189251
// Rate limiting middleware (basic implementation)
190252
const requestCounts = new Map<string, { count: number; resetTime: number }>();
191253
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
192-
const RATE_LIMIT_MAX_REQUESTS = 1000; // Max requests per window
254+
255+
// Cleanup rate limit map every 5 minutes
256+
setInterval(() => {
257+
const now = Date.now();
258+
for (const [ip, data] of requestCounts.entries()) {
259+
if (now > data.resetTime) {
260+
requestCounts.delete(ip);
261+
}
262+
}
263+
}, 5 * 60 * 1000).unref();
264+
265+
const RATE_LIMIT_MAX_REQUESTS = (() => {
266+
const parsed = Number(process.env.RATE_LIMIT_MAX_REQUESTS);
267+
if (!Number.isFinite(parsed) || parsed <= 0) {
268+
return 1000;
269+
}
270+
return parsed;
271+
})(); // Max requests per window
193272

194273
app.use((req, res, next) => {
195274
const ip = req.ip || req.connection.remoteAddress || "unknown";
@@ -486,36 +565,84 @@ app.get("/health", (req, res) => {
486565
// GET /drawings
487566
app.get("/drawings", async (req, res) => {
488567
try {
489-
const { search, collectionId } = req.query;
568+
const { search, collectionId, includeData } = req.query;
490569
const where: any = {};
570+
const searchTerm =
571+
typeof search === "string" && search.trim().length > 0
572+
? search.trim()
573+
: undefined;
491574

492-
if (search) {
493-
where.name = { contains: String(search) };
575+
if (searchTerm) {
576+
where.name = { contains: searchTerm };
494577
}
495578

579+
let collectionFilterKey = "default";
496580
if (collectionId === "null") {
497581
where.collectionId = null;
582+
collectionFilterKey = "null";
498583
} else if (collectionId) {
499-
where.collectionId = String(collectionId);
584+
const normalizedCollectionId = String(collectionId);
585+
where.collectionId = normalizedCollectionId;
586+
collectionFilterKey = `id:${normalizedCollectionId}`;
500587
} else {
501588
// Default: Exclude trash, but include unorganized (null)
502589
where.OR = [{ collectionId: { not: "trash" } }, { collectionId: null }];
503590
}
504591

505-
const drawings = await prisma.drawing.findMany({
592+
const shouldIncludeData =
593+
typeof includeData === "string"
594+
? includeData.toLowerCase() === "true" || includeData === "1"
595+
: false;
596+
597+
const cacheKey = buildDrawingsCacheKey({
598+
searchTerm: searchTerm ?? "",
599+
collectionFilter: collectionFilterKey,
600+
includeData: shouldIncludeData,
601+
});
602+
603+
const cachedBody = getCachedDrawingsBody(cacheKey);
604+
if (cachedBody) {
605+
res.setHeader("X-Cache", "HIT");
606+
res.setHeader("Content-Type", "application/json");
607+
return res.send(cachedBody);
608+
}
609+
610+
const summarySelect: Prisma.DrawingSelect = {
611+
id: true,
612+
name: true,
613+
collectionId: true,
614+
preview: true,
615+
version: true,
616+
createdAt: true,
617+
updatedAt: true,
618+
};
619+
620+
const queryOptions: Prisma.DrawingFindManyArgs = {
506621
where,
507622
orderBy: { updatedAt: "desc" },
508-
});
623+
};
509624

510-
// Parse JSON strings for response
511-
const parsedDrawings = drawings.map((d: any) => ({
512-
...d,
513-
elements: JSON.parse(d.elements),
514-
appState: JSON.parse(d.appState),
515-
files: JSON.parse(d.files || "{}"),
516-
}));
625+
if (!shouldIncludeData) {
626+
queryOptions.select = summarySelect;
627+
}
628+
629+
const drawings = await prisma.drawing.findMany(queryOptions);
630+
631+
let responsePayload: any = drawings;
632+
633+
if (shouldIncludeData) {
634+
responsePayload = drawings.map((d: any) => ({
635+
...d,
636+
elements: parseJsonField(d.elements, []),
637+
appState: parseJsonField(d.appState, {}),
638+
files: parseJsonField(d.files, {}),
639+
}));
640+
}
517641

518-
res.json(parsedDrawings);
642+
const body = cacheDrawingsResponse(cacheKey, responsePayload);
643+
res.setHeader("X-Cache", "MISS");
644+
res.setHeader("Content-Type", "application/json");
645+
return res.send(body);
519646
} catch (error) {
520647
console.error(error);
521648
res.status(500).json({ error: "Failed to fetch drawings" });
@@ -591,6 +718,7 @@ app.post("/drawings", async (req, res) => {
591718
files: JSON.stringify(payload.files ?? {}),
592719
},
593720
});
721+
invalidateDrawingsCache();
594722

595723
res.json({
596724
...newDrawing,
@@ -668,6 +796,7 @@ app.put("/drawings/:id", async (req, res) => {
668796
where: { id },
669797
data,
670798
});
799+
invalidateDrawingsCache();
671800

672801
console.log("[API] Update complete", {
673802
id,
@@ -698,6 +827,7 @@ app.delete("/drawings/:id", async (req, res) => {
698827
try {
699828
const { id } = req.params;
700829
await prisma.drawing.delete({ where: { id } });
830+
invalidateDrawingsCache();
701831
res.json({ success: true });
702832
} catch (error) {
703833
res.status(500).json({ error: "Failed to delete drawing" });
@@ -724,6 +854,7 @@ app.post("/drawings/:id/duplicate", async (req, res) => {
724854
version: 1,
725855
},
726856
});
857+
invalidateDrawingsCache();
727858

728859
res.json({
729860
...newDrawing,
@@ -794,6 +925,7 @@ app.delete("/collections/:id", async (req, res) => {
794925
where: { id },
795926
}),
796927
]);
928+
invalidateDrawingsCache();
797929

798930
res.json({ success: true });
799931
} catch (error) {
@@ -1061,6 +1193,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
10611193
await prisma.$disconnect();
10621194

10631195
res.json({ success: true, message: "Database imported successfully" });
1196+
invalidateDrawingsCache();
10641197
} catch (error) {
10651198
console.error(error);
10661199
if (req.file) {

frontend/src/api/index.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import axios from "axios";
2-
import type { Drawing, Collection } from "../types";
2+
import type { Drawing, Collection, DrawingSummary } from "../types";
33

44
export const API_URL = import.meta.env.VITE_API_URL || "/api";
55

@@ -14,23 +14,48 @@ const coerceTimestamp = (value: string | number | Date): number => {
1414
return Number.isNaN(parsed) ? Date.now() : parsed;
1515
};
1616

17-
const deserializeDrawing = (drawing: any): Drawing => ({
18-
...drawing,
19-
createdAt: coerceTimestamp(drawing.createdAt),
20-
updatedAt: coerceTimestamp(drawing.updatedAt),
17+
const deserializeTimestamps = <T extends { createdAt: any; updatedAt: any }>(
18+
data: T
19+
): T & { createdAt: number; updatedAt: number } => ({
20+
...data,
21+
createdAt: coerceTimestamp(data.createdAt),
22+
updatedAt: coerceTimestamp(data.updatedAt),
2123
});
2224

23-
export const getDrawings = async (
25+
const deserializeDrawingSummary = (drawing: any): DrawingSummary =>
26+
deserializeTimestamps(drawing);
27+
28+
const deserializeDrawing = (drawing: any): Drawing =>
29+
deserializeTimestamps(drawing);
30+
31+
export function getDrawings(
2432
search?: string,
2533
collectionId?: string | null
26-
) => {
34+
): Promise<DrawingSummary[]>;
35+
36+
export function getDrawings(
37+
search: string | undefined,
38+
collectionId: string | null | undefined,
39+
options: { includeData: true }
40+
): Promise<Drawing[]>;
41+
42+
export async function getDrawings(
43+
search?: string,
44+
collectionId?: string | null,
45+
options?: { includeData?: boolean }
46+
) {
2747
const params: any = {};
2848
if (search) params.search = search;
2949
if (collectionId !== undefined)
3050
params.collectionId = collectionId === null ? "null" : collectionId;
31-
const response = await api.get<Drawing[]>("/drawings", { params });
32-
return response.data.map(deserializeDrawing);
33-
};
51+
if (options?.includeData) {
52+
params.includeData = "true";
53+
const response = await api.get<Drawing[]>("/drawings", { params });
54+
return response.data.map(deserializeDrawing);
55+
}
56+
const response = await api.get<DrawingSummary[]>("/drawings", { params });
57+
return response.data.map(deserializeDrawingSummary);
58+
}
3459

3560
export const getDrawing = async (id: string) => {
3661
const response = await api.get<Drawing>(`/drawings/${id}`);

0 commit comments

Comments
 (0)