Merge pull request #15 from AdrianAcala/perf/drawings-optim

perf: optimize drawings endpoint with caching and lazy loading
This commit is contained in:
Zimeng Xiong
2025-12-01 13:59:08 -08:00
committed by GitHub
6 changed files with 394 additions and 57 deletions
+74 -1
View File
@@ -1,3 +1,76 @@
# Dependencies
frontend/node_modules frontend/node_modules
.DS_Store backend/node_modules
# Database
backend/prisma/*.db backend/prisma/*.db
backend/prisma/dev.db
# Generated files
backend/src/generated/
# Environment variables
.env
.env.local
.env.production
.env.staging
# Build outputs
frontend/dist/
frontend/build/
backend/dist/
# Logs
*.log
logs/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env.test
# IDE/Editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Temporary files
*.tmp
*.temp
+163 -17
View File
@@ -11,7 +11,7 @@ import multer from "multer";
import archiver from "archiver"; import archiver from "archiver";
import { z } from "zod"; import { z } from "zod";
// @ts-ignore // @ts-ignore
import { PrismaClient } from "./generated/client"; import { PrismaClient, Prisma } from "./generated/client";
import { import {
sanitizeDrawingData, sanitizeDrawingData,
validateImportedDrawing, validateImportedDrawing,
@@ -112,6 +112,81 @@ const io = new Server(httpServer, {
maxHttpBufferSize: 1e8, // 100 MB maxHttpBufferSize: 1e8, // 100 MB
}); });
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const parseJsonField = <T>(
rawValue: string | null | undefined,
fallback: T
): T => {
if (!rawValue) return fallback;
try {
return JSON.parse(rawValue) as T;
} catch (error) {
console.warn("Failed to parse JSON field", {
error,
valuePreview: rawValue.slice(0, 50),
});
return fallback;
}
};
const DRAWINGS_CACHE_TTL_MS = (() => {
const parsed = Number(process.env.DRAWINGS_CACHE_TTL_MS);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 5_000;
}
return parsed;
})();
type DrawingsCacheEntry = { body: Buffer; expiresAt: number };
const drawingsCache = new Map<string, DrawingsCacheEntry>();
/**
* Builds a cache key for the drawings list endpoint.
* NOTE: This key does NOT include sort order. If sorting options are added
* to the endpoint in the future, they must be included in this key.
*/
const buildDrawingsCacheKey = (keyParts: {
searchTerm: string;
collectionFilter: string;
includeData: boolean;
}) =>
JSON.stringify([
keyParts.searchTerm,
keyParts.collectionFilter,
keyParts.includeData ? "full" : "summary",
]);
const getCachedDrawingsBody = (key: string): Buffer | null => {
const entry = drawingsCache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
drawingsCache.delete(key);
return null;
}
return entry.body;
};
const cacheDrawingsResponse = (key: string, payload: any): Buffer => {
const body = Buffer.from(JSON.stringify(payload));
drawingsCache.set(key, {
body,
expiresAt: Date.now() + DRAWINGS_CACHE_TTL_MS,
});
return body;
};
const invalidateDrawingsCache = () => {
drawingsCache.clear();
};
// Cleanup cache every 60 seconds
setInterval(() => {
const now = Date.now();
for (const [key, entry] of drawingsCache.entries()) {
if (now > entry.expiresAt) {
drawingsCache.delete(key);
}
}
}, 60_000).unref(); // unref so it doesn't keep the process alive if everything else stops
const PORT = process.env.PORT || 8000; const PORT = process.env.PORT || 8000;
// Multer setup for file uploads with streaming support // Multer setup for file uploads with streaming support
@@ -189,7 +264,24 @@ app.use((req, res, next) => {
// Rate limiting middleware (basic implementation) // Rate limiting middleware (basic implementation)
const requestCounts = new Map<string, { count: number; resetTime: number }>(); const requestCounts = new Map<string, { count: number; resetTime: number }>();
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
const RATE_LIMIT_MAX_REQUESTS = 1000; // Max requests per window
// Cleanup rate limit map every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [ip, data] of requestCounts.entries()) {
if (now > data.resetTime) {
requestCounts.delete(ip);
}
}
}, 5 * 60 * 1000).unref();
const RATE_LIMIT_MAX_REQUESTS = (() => {
const parsed = Number(process.env.RATE_LIMIT_MAX_REQUESTS);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 1000;
}
return parsed;
})(); // Max requests per window
app.use((req, res, next) => { app.use((req, res, next) => {
const ip = req.ip || req.connection.remoteAddress || "unknown"; const ip = req.ip || req.connection.remoteAddress || "unknown";
@@ -486,36 +578,84 @@ app.get("/health", (req, res) => {
// GET /drawings // GET /drawings
app.get("/drawings", async (req, res) => { app.get("/drawings", async (req, res) => {
try { try {
const { search, collectionId } = req.query; const { search, collectionId, includeData } = req.query;
const where: any = {}; const where: any = {};
const searchTerm =
typeof search === "string" && search.trim().length > 0
? search.trim()
: undefined;
if (search) { if (searchTerm) {
where.name = { contains: String(search) }; where.name = { contains: searchTerm };
} }
let collectionFilterKey = "default";
if (collectionId === "null") { if (collectionId === "null") {
where.collectionId = null; where.collectionId = null;
collectionFilterKey = "null";
} else if (collectionId) { } else if (collectionId) {
where.collectionId = String(collectionId); const normalizedCollectionId = String(collectionId);
where.collectionId = normalizedCollectionId;
collectionFilterKey = `id:${normalizedCollectionId}`;
} else { } else {
// Default: Exclude trash, but include unorganized (null) // Default: Exclude trash, but include unorganized (null)
where.OR = [{ collectionId: { not: "trash" } }, { collectionId: null }]; where.OR = [{ collectionId: { not: "trash" } }, { collectionId: null }];
} }
const drawings = await prisma.drawing.findMany({ const shouldIncludeData =
where, typeof includeData === "string"
orderBy: { updatedAt: "desc" }, ? includeData.toLowerCase() === "true" || includeData === "1"
: false;
const cacheKey = buildDrawingsCacheKey({
searchTerm: searchTerm ?? "",
collectionFilter: collectionFilterKey,
includeData: shouldIncludeData,
}); });
// Parse JSON strings for response const cachedBody = getCachedDrawingsBody(cacheKey);
const parsedDrawings = drawings.map((d: any) => ({ if (cachedBody) {
...d, res.setHeader("X-Cache", "HIT");
elements: JSON.parse(d.elements), res.setHeader("Content-Type", "application/json");
appState: JSON.parse(d.appState), return res.send(cachedBody);
files: JSON.parse(d.files || "{}"), }
}));
res.json(parsedDrawings); const summarySelect: Prisma.DrawingSelect = {
id: true,
name: true,
collectionId: true,
preview: true,
version: true,
createdAt: true,
updatedAt: true,
};
const queryOptions: Prisma.DrawingFindManyArgs = {
where,
orderBy: { updatedAt: "desc" },
};
if (!shouldIncludeData) {
queryOptions.select = summarySelect;
}
const drawings = await prisma.drawing.findMany(queryOptions);
let responsePayload: any = drawings;
if (shouldIncludeData) {
responsePayload = drawings.map((d: any) => ({
...d,
elements: parseJsonField(d.elements, []),
appState: parseJsonField(d.appState, {}),
files: parseJsonField(d.files, {}),
}));
}
const body = cacheDrawingsResponse(cacheKey, responsePayload);
res.setHeader("X-Cache", "MISS");
res.setHeader("Content-Type", "application/json");
return res.send(body);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.status(500).json({ error: "Failed to fetch drawings" }); res.status(500).json({ error: "Failed to fetch drawings" });
@@ -591,6 +731,7 @@ app.post("/drawings", async (req, res) => {
files: JSON.stringify(payload.files ?? {}), files: JSON.stringify(payload.files ?? {}),
}, },
}); });
invalidateDrawingsCache();
res.json({ res.json({
...newDrawing, ...newDrawing,
@@ -668,6 +809,7 @@ app.put("/drawings/:id", async (req, res) => {
where: { id }, where: { id },
data, data,
}); });
invalidateDrawingsCache();
console.log("[API] Update complete", { console.log("[API] Update complete", {
id, id,
@@ -698,6 +840,7 @@ app.delete("/drawings/:id", async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
await prisma.drawing.delete({ where: { id } }); await prisma.drawing.delete({ where: { id } });
invalidateDrawingsCache();
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
res.status(500).json({ error: "Failed to delete drawing" }); res.status(500).json({ error: "Failed to delete drawing" });
@@ -724,6 +867,7 @@ app.post("/drawings/:id/duplicate", async (req, res) => {
version: 1, version: 1,
}, },
}); });
invalidateDrawingsCache();
res.json({ res.json({
...newDrawing, ...newDrawing,
@@ -794,6 +938,7 @@ app.delete("/collections/:id", async (req, res) => {
where: { id }, where: { id },
}), }),
]); ]);
invalidateDrawingsCache();
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
@@ -1059,6 +1204,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
// Reinitialize Prisma client // Reinitialize Prisma client
await prisma.$disconnect(); await prisma.$disconnect();
invalidateDrawingsCache();
res.json({ success: true, message: "Database imported successfully" }); res.json({ success: true, message: "Database imported successfully" });
} catch (error) { } catch (error) {
+35 -10
View File
@@ -1,5 +1,5 @@
import axios from "axios"; import axios from "axios";
import type { Drawing, Collection } from "../types"; import type { Drawing, Collection, DrawingSummary } from "../types";
export const API_URL = import.meta.env.VITE_API_URL || "/api"; export const API_URL = import.meta.env.VITE_API_URL || "/api";
@@ -14,23 +14,48 @@ const coerceTimestamp = (value: string | number | Date): number => {
return Number.isNaN(parsed) ? Date.now() : parsed; return Number.isNaN(parsed) ? Date.now() : parsed;
}; };
const deserializeDrawing = (drawing: any): Drawing => ({ const deserializeTimestamps = <T extends { createdAt: any; updatedAt: any }>(
...drawing, data: T
createdAt: coerceTimestamp(drawing.createdAt), ): T & { createdAt: number; updatedAt: number } => ({
updatedAt: coerceTimestamp(drawing.updatedAt), ...data,
createdAt: coerceTimestamp(data.createdAt),
updatedAt: coerceTimestamp(data.updatedAt),
}); });
export const getDrawings = async ( const deserializeDrawingSummary = (drawing: any): DrawingSummary =>
deserializeTimestamps(drawing);
const deserializeDrawing = (drawing: any): Drawing =>
deserializeTimestamps(drawing);
export function getDrawings(
search?: string, search?: string,
collectionId?: string | null collectionId?: string | null
) => { ): Promise<DrawingSummary[]>;
export function getDrawings(
search: string | undefined,
collectionId: string | null | undefined,
options: { includeData: true }
): Promise<Drawing[]>;
export async function getDrawings(
search?: string,
collectionId?: string | null,
options?: { includeData?: boolean }
) {
const params: any = {}; const params: any = {};
if (search) params.search = search; if (search) params.search = search;
if (collectionId !== undefined) if (collectionId !== undefined)
params.collectionId = collectionId === null ? "null" : collectionId; params.collectionId = collectionId === null ? "null" : collectionId;
const response = await api.get<Drawing[]>("/drawings", { params }); if (options?.includeData) {
return response.data.map(deserializeDrawing); params.includeData = "true";
}; const response = await api.get<Drawing[]>("/drawings", { params });
return response.data.map(deserializeDrawing);
}
const response = await api.get<DrawingSummary[]>("/drawings", { params });
return response.data.map(deserializeDrawingSummary);
}
export const getDrawing = async (id: string) => { export const getDrawing = async (id: string) => {
const response = await api.get<Drawing>(`/drawings/${id}`); const response = await api.get<Drawing>(`/drawings/${id}`);
+107 -18
View File
@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy, Download } from 'lucide-react'; import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy, Download, Loader2 } from 'lucide-react';
import type { Drawing, Collection } from '../types'; import type { DrawingSummary, Collection, Drawing } from '../types';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import clsx from 'clsx'; import clsx from 'clsx';
import { exportToSvg } from "@excalidraw/excalidraw"; import { exportToSvg } from "@excalidraw/excalidraw";
@@ -10,8 +10,14 @@ import { exportDrawingToFile } from '../utils/exportUtils';
import * as api from '../api'; import * as api from '../api';
type HydratedDrawingData = {
elements: any[];
appState: any;
files: Record<string, any>;
};
interface DrawingCardProps { interface DrawingCardProps {
drawing: Drawing; drawing: DrawingSummary;
collections: Collection[]; collections: Collection[];
isSelected: boolean; isSelected: boolean;
isTrash?: boolean; isTrash?: boolean;
@@ -49,30 +55,74 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
const [showMoveSubmenu, setShowMoveSubmenu] = useState(false); const [showMoveSubmenu, setShowMoveSubmenu] = useState(false);
const [showCollectionDropdown, setShowCollectionDropdown] = useState(false); const [showCollectionDropdown, setShowCollectionDropdown] = useState(false);
const [newName, setNewName] = useState(drawing.name); const [newName, setNewName] = useState(drawing.name);
const [previewSvg, setPreviewSvg] = useState<string | null>(null); const [previewSvg, setPreviewSvg] = useState<string | null>(drawing.preview ?? null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [fullData, setFullData] = useState<HydratedDrawingData | null>(null);
const fullDataRef = React.useRef(fullData);
fullDataRef.current = fullData;
const fullDataPromiseRef = React.useRef<Promise<HydratedDrawingData> | null>(null);
useEffect(() => { useEffect(() => {
setFullData(null);
fullDataPromiseRef.current = null;
}, [drawing.id]);
const drawingIdRef = React.useRef(drawing.id);
drawingIdRef.current = drawing.id;
const ensureFullData = useCallback(async (): Promise<HydratedDrawingData> => {
if (fullDataRef.current) {
return fullDataRef.current;
}
if (fullDataPromiseRef.current) {
return fullDataPromiseRef.current;
}
const currentDrawingId = drawingIdRef.current;
const promise = api.getDrawing(currentDrawingId).then((fullDrawing) => {
const payload: HydratedDrawingData = {
elements: fullDrawing.elements || [],
appState: fullDrawing.appState || {},
files: fullDrawing.files || {},
};
setFullData(payload);
fullDataPromiseRef.current = null;
return payload;
}).catch((error) => {
fullDataPromiseRef.current = null;
throw error;
});
fullDataPromiseRef.current = promise;
return promise;
}, []); // Stable identity - uses refs internally
useEffect(() => {
let cancelled = false;
if (drawing.preview) { if (drawing.preview) {
setPreviewSvg(drawing.preview); setPreviewSvg(drawing.preview);
return; return;
} }
const generatePreview = async () => { const generatePreview = async () => {
// Ensure elements and appState exist before trying to generate
if (!drawing.elements || !drawing.appState) return;
try { try {
const data = await ensureFullData();
if (cancelled) return;
if (!data?.elements || !data?.appState) return;
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: drawing.elements, elements: data.elements,
appState: { appState: {
...drawing.appState, ...data.appState,
exportBackground: true, exportBackground: true,
viewBackgroundColor: drawing.appState.viewBackgroundColor || "#ffffff" viewBackgroundColor: data.appState.viewBackgroundColor || "#ffffff"
}, },
files: drawing.files || {}, files: data.files || {},
exportPadding: 10 exportPadding: 10
}); });
if (cancelled) return;
const previewHtml = svg.outerHTML; const previewHtml = svg.outerHTML;
setPreviewSvg(previewHtml); setPreviewSvg(previewHtml);
@@ -80,11 +130,42 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
api.updateDrawing(drawing.id, { preview: previewHtml }).catch(console.error); api.updateDrawing(drawing.id, { preview: previewHtml }).catch(console.error);
onPreviewGenerated?.(drawing.id, previewHtml); onPreviewGenerated?.(drawing.id, previewHtml);
} catch (e) { } catch (e) {
console.error("Failed to generate preview", e); if (!cancelled) {
console.error("Failed to generate preview", e);
}
} }
}; };
generatePreview(); generatePreview();
}, [drawing, onPreviewGenerated]);
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [drawing.id, drawing.preview, onPreviewGenerated]); // ensureFullData has stable identity via refs
const handleExport = useCallback(async () => {
try {
setIsExporting(true);
setExportError(null);
const data = await ensureFullData();
const drawingPayload: Drawing = {
...drawing,
elements: data.elements || [],
appState: data.appState || {},
files: data.files || {},
};
exportDrawingToFile(drawingPayload);
} catch (error) {
console.error("Failed to export drawing", error);
setExportError("Failed to export drawing. Please try again.");
// Clear error after 3 seconds
setTimeout(() => setExportError(null), 3000);
} finally {
setIsExporting(false);
}
}, [drawing, ensureFullData]);
// Close context menu on click outside // Close context menu on click outside
useEffect(() => { useEffect(() => {
@@ -327,14 +408,22 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
</button> </button>
<button <button
onClick={() => { onClick={async (e) => {
exportDrawingToFile(drawing); e.stopPropagation();
await handleExport();
setContextMenu(null); setContextMenu(null);
}} }}
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center gap-2" disabled={isExporting}
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<Download size={14} /> Export {isExporting ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} />}
{isExporting ? 'Exporting...' : 'Export'}
</button> </button>
{exportError && (
<div className="px-3 py-2 text-xs text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-900/20">
{exportError}
</div>
)}
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div> <div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
+2 -2
View File
@@ -5,7 +5,7 @@ import { DrawingCard } from '../components/DrawingCard';
import { Plus, Search, Loader2, Inbox, Trash2, Folder, ArrowRight, Copy, Upload } from 'lucide-react'; import { Plus, Search, Loader2, Inbox, Trash2, Folder, ArrowRight, Copy, Upload } from 'lucide-react';
import { useNavigate, useSearchParams, useLocation } from 'react-router-dom'; import { useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import * as api from '../api'; import * as api from '../api';
import type { Drawing, Collection } from '../types'; import type { DrawingSummary, Collection } from '../types';
import { useDebounce } from '../hooks/useDebounce'; import { useDebounce } from '../hooks/useDebounce';
import clsx from 'clsx'; import clsx from 'clsx';
import { ConfirmModal } from '../components/ConfirmModal'; import { ConfirmModal } from '../components/ConfirmModal';
@@ -45,7 +45,7 @@ export const Dashboard: React.FC = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [drawings, setDrawings] = useState<Drawing[]>([]); const [drawings, setDrawings] = useState<DrawingSummary[]>([]);
const [collections, setCollections] = useState<Collection[]>([]); const [collections, setCollections] = useState<Collection[]>([]);
// Derived state from URL // Derived state from URL
+9 -5
View File
@@ -1,13 +1,17 @@
export interface Drawing { export interface DrawingSummary {
id: string; id: string;
name: string; name: string;
elements: any[];
appState: any;
files: Record<string, any> | null;
collectionId: string | null; collectionId: string | null;
updatedAt: number; updatedAt: number;
createdAt: number; createdAt: number;
preview?: string; version: number;
preview?: string | null;
}
export interface Drawing extends DrawingSummary {
elements: any[];
appState: any;
files: Record<string, any> | null;
} }
export interface Collection { export interface Collection {