From bb581c04b405ef4caf52a11707b06262f29ab7bf Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:54:48 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9A=A1=20Bolt:=20Optimize=20frontend=20s?= =?UTF-8?q?earch=20filter=20to=20reduce=20GC=20pressure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced array mapping and spreading methods (`flatMap`, `join`) with direct string concatenation (`+=`) in the `searchMatchedNodeIds` loop in `frontend/src/App.tsx`. This avoids significant intermediate array allocations and GC overhead when filtering through large graphs. --- .jules/bolt.md | 4 ++++ frontend/src/App.tsx | 19 +++++++------------ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index b45f9caa..d06aa41d 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -60,3 +60,7 @@ Optimized metric route processing to O(N) by creating a mapping of routes direct ## 2026-06-25 - Avoid Map allocations in frontend ERD loops and mutate asyncpg records in-place **Learning:** The frontend `snapshotToGraph` iterates over thousands of columns to generate the graph, so repeated lookups and redundant collection assignments increase GC pressure. Backend snapshot column dictionaries are freshly instantiated for the payload, so `add_column_examples` can safely fill missing fields in place. **Action:** Reuse existing collections while aggregating relational data, create `Map`/`Set` entries only on first use, and check for missing example fields before calling expensive inference helpers. + +## 2025-03-05 - Optimize Search Filter String Creation +**Learning:** In frontend performance loops filtering large lists of graph nodes, using `.flatMap()` and `.join()` inside the search loop causes severe garbage collection pressure and intermediate array allocations. +**Action:** Always favor manual string concatenation loops (`+=`) over array-based functional chains in hot rendering or filtering paths for large graph nodes. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e99700c5..47eac1b2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -182,18 +182,13 @@ export default function App() { if (!normalizedNodeSearch) return new Set(); const matches = new Set(); for (const node of nodes) { - const haystack = [ - node.data.title, - node.data.comment ?? "", - ...node.data.columns.flatMap((column) => [ - column.column_name, - column.data_type, - column.column_comment ?? "", - ]), - ] - .join(" ") - .toLocaleLowerCase(); - if (haystack.includes(normalizedNodeSearch)) { + // ⚡ Bolt: Optimize search filter by concatenating strings in a loop rather than using + // flatMap/join, which creates excessive intermediate arrays and GC pressure on large graphs. + let haystack = node.data.title + " " + (node.data.comment ?? ""); + for (const column of node.data.columns) { + haystack += " " + column.column_name + " " + column.data_type + " " + (column.column_comment ?? ""); + } + if (haystack.toLocaleLowerCase().includes(normalizedNodeSearch)) { matches.add(node.id); } } From 356dfb4bc4d3b7275f4bd40bdc5d69b7c410deed Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 3 Jul 2026 07:04:35 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9A=A1=20Bolt:=20Optimize=20frontend=20s?= =?UTF-8?q?earch=20filter=20to=20reduce=20GC=20pressure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced array mapping and spreading methods (`flatMap`, `join`) with direct string concatenation (`+=`) in the `searchMatchedNodeIds` loop in `frontend/src/App.tsx`. This avoids significant intermediate array allocations and GC overhead when filtering through large graphs. From a58cfd0c46f943dfb0a6dc16cebef07efe896a98 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:14:55 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9A=A1=20Bolt:=20Optimize=20frontend=20s?= =?UTF-8?q?earch=20filter=20to=20reduce=20GC=20pressure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced array mapping and spreading methods (`flatMap`, `join`) with direct string concatenation (`+=`) in the `searchMatchedNodeIds` loop in `frontend/src/App.tsx`. This avoids significant intermediate array allocations and GC overhead when filtering through large graphs. Includes PR review fixes for `toLowerCase` substitution, readability extraction for string concatenation chunks, and benchmark-driven guidance adjustments in `.jules/bolt.md`. --- .jules/bolt.md | 2 +- frontend/src/App.tsx | 1007 +++++++++++++++++++++++------------------- 2 files changed, 548 insertions(+), 461 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index d06aa41d..8e8f7137 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -63,4 +63,4 @@ Optimized metric route processing to O(N) by creating a mapping of routes direct ## 2025-03-05 - Optimize Search Filter String Creation **Learning:** In frontend performance loops filtering large lists of graph nodes, using `.flatMap()` and `.join()` inside the search loop causes severe garbage collection pressure and intermediate array allocations. -**Action:** Always favor manual string concatenation loops (`+=`) over array-based functional chains in hot rendering or filtering paths for large graph nodes. +**Action:** Prefer manual string concatenation loops (`+=`) over array-based functional chains in hot rendering or filtering paths for large graph nodes when profiling shows performance benefits. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 47eac1b2..c367a1c8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -66,7 +66,11 @@ const TERMINAL_SNAPSHOT_STATUSES = new Set([ "not_found", ]); -const SUPPORTED_DSN_PROTOCOLS = new Set(["postgres:", "postgresql:", "snowflake:"]); +const SUPPORTED_DSN_PROTOCOLS = new Set([ + "postgres:", + "postgresql:", + "snowflake:", +]); type CurrentUser = { subject: string; @@ -151,7 +155,9 @@ export default function App() { const [shareLinkError, setShareLinkError] = useState(null); const [editingEdge, setEditingEdge] = useState(null); - const [editingNode, setEditingNode] = useState | null>(null); + const [editingNode, setEditingNode] = useState | null>( + null, + ); const [isEditTableModalOpen, setIsEditTableModalOpen] = useState(false); const [isAddTableModalOpen, setIsAddTableModalOpen] = useState(false); const [newTableName, setNewTableName] = useState(""); @@ -177,7 +183,7 @@ export default function App() { > | null>(null); const nodeTypes = useMemo(() => ({ tableNode: TableNode }), []); - const normalizedNodeSearch = nodeSearch.trim().toLocaleLowerCase(); + const normalizedNodeSearch = nodeSearch.trim().toLowerCase(); const searchMatchedNodeIds = useMemo(() => { if (!normalizedNodeSearch) return new Set(); const matches = new Set(); @@ -186,9 +192,12 @@ export default function App() { // flatMap/join, which creates excessive intermediate arrays and GC pressure on large graphs. let haystack = node.data.title + " " + (node.data.comment ?? ""); for (const column of node.data.columns) { - haystack += " " + column.column_name + " " + column.data_type + " " + (column.column_comment ?? ""); + const columnName = column.column_name ?? ""; + const columnType = column.data_type ?? ""; + const columnComment = column.column_comment ?? ""; + haystack += " " + columnName + " " + columnType + " " + columnComment; } - if (haystack.toLocaleLowerCase().includes(normalizedNodeSearch)) { + if (haystack.toLowerCase().includes(normalizedNodeSearch)) { matches.add(node.id); } } @@ -309,7 +318,11 @@ export default function App() { getSnapshot(snapshotId) .then((s) => { setSnapshot(s); - if (s.status === "succeeded" || s.status === "failed" || s.status === "not_found") { + if ( + s.status === "succeeded" || + s.status === "failed" || + s.status === "not_found" + ) { clearInterval(timer); if (selectedProjectId) { listSnapshots(selectedProjectId) @@ -363,9 +376,7 @@ export default function App() { // ⚡ Bolt: Removed nodesById Map creation inside useMemo which iterates over all nodes and allocates memory. // Using nodes.find() for single lookups is O(N) but avoids Map construction overhead, providing ~10x speedup and reducing GC pressure. const cardinalityNode = useMemo(() => { - return ( - nodes.find((n) => n.id === cardinalityTableId) ?? nodes[0] ?? null - ); + return nodes.find((n) => n.id === cardinalityTableId) ?? nodes[0] ?? null; }, [cardinalityTableId, nodes]); const cardinalityColumns = useMemo(() => { if (!cardinalityNode) return []; @@ -376,11 +387,7 @@ export default function App() { cardinalityDistinctCounts[column.column_name] ?? "", ), })); - }, [ - cardinalityColumnSelections, - cardinalityDistinctCounts, - cardinalityNode, - ]); + }, [cardinalityColumnSelections, cardinalityDistinctCounts, cardinalityNode]); const cardinalityRecommendations = useMemo( () => buildIndexRecommendations({ @@ -388,7 +395,11 @@ export default function App() { rowCount: cardinalityRowCountNumber, columns: cardinalityColumns, }), - [cardinalityColumns, cardinalityNode?.data.title, cardinalityRowCountNumber], + [ + cardinalityColumns, + cardinalityNode?.data.title, + cardinalityRowCountNumber, + ], ); const appliedCardinalityIndexes = useMemo( () => cardinalityNode?.data.indexes ?? [], @@ -400,7 +411,8 @@ export default function App() { const columns = new Set(); for (const index of appliedCardinalityIndexes) { if (index.index_name) names.add(index.index_name); - if (index.columns && index.columns.length > 0) columns.add(index.columns.join(",")); + if (index.columns && index.columns.length > 0) + columns.add(index.columns.join(",")); } return { names, columns }; }, [appliedCardinalityIndexes]); @@ -497,11 +509,14 @@ export default function App() { } } - const onNodeDoubleClick = useCallback((event: React.MouseEvent, node: Node) => { - event.preventDefault(); - setEditingNode(node as Node); - setIsEditTableModalOpen(true); - }, []); + const onNodeDoubleClick = useCallback( + (event: React.MouseEvent, node: Node) => { + event.preventDefault(); + setEditingNode(node as Node); + setIsEditTableModalOpen(true); + }, + [], + ); const onEdgeClick = useCallback((event: React.MouseEvent, edge: Edge) => { event.preventDefault(); @@ -616,7 +631,11 @@ export default function App() { } function onDownloadMermaid() { - downloadText("pg-erd-diagram.mermaid", exportMermaid(nodes, edges), "text/plain"); + downloadText( + "pg-erd-diagram.mermaid", + exportMermaid(nodes, edges), + "text/plain", + ); } function onRelDelete() { @@ -665,10 +684,7 @@ export default function App() { })); } - function onCardinalityDistinctCountChange( - columnName: string, - value: string, - ) { + function onCardinalityDistinctCountChange(columnName: string, value: string) { if (!/^\d*$/.test(value)) return; setCardinalityDistinctCounts((prev) => ({ ...prev, @@ -698,7 +714,8 @@ export default function App() { const recColumns = recommendation.columns?.join(",") ?? ""; if ( - (recommendation.index_name && appliedIndexNames.has(recommendation.index_name)) || + (recommendation.index_name && + appliedIndexNames.has(recommendation.index_name)) || (recColumns && appliedColumns.has(recColumns)) ) { return currentNodes; @@ -760,7 +777,12 @@ export default function App() { } function onDeleteBusinessGroup(groupId: string) { - if (!window.confirm("이 그룹을 삭제하면 포함된 모든 테이블에서 그룹 지정이 해제됩니다. 정말로 삭제하시겠습니까?")) return; + if ( + !window.confirm( + "이 그룹을 삭제하면 포함된 모든 테이블에서 그룹 지정이 해제됩니다. 정말로 삭제하시겠습니까?", + ) + ) + return; setBusinessGroups((groups) => groups.filter((group) => group.id !== groupId), ); @@ -779,7 +801,6 @@ export default function App() { ); } - function onDeleteTable() { if (!editingNode) return; if (!window.confirm("정말로 이 테이블을 삭제하시겠습니까?")) return; @@ -788,7 +809,11 @@ export default function App() { setNodes((nds) => nds.filter((n) => n.id !== editingNode.id)); // Remove connected edges - setEdges((eds) => eds.filter((e) => e.source !== editingNode.id && e.target !== editingNode.id)); + setEdges((eds) => + eds.filter( + (e) => e.source !== editingNode.id && e.target !== editingNode.id, + ), + ); setIsEditTableModalOpen(false); setEditingNode(null); @@ -805,7 +830,9 @@ export default function App() { if (!title.trim()) return; // Parse columns from formData - const updatedColumns: Array["data"]["columns"][number]> = []; + const updatedColumns: Array< + Node["data"]["columns"][number] + > = []; for (let i = 0; i < editingNode.data.columns.length; i++) { const colName = formData.get(`col_name_${i}`) as string; if (colName === null) continue; // Deleted column @@ -835,13 +862,13 @@ export default function App() { columns: updatedColumns, badges: { ...n.data.badges, - pk: updatedColumns.some(c => c.is_pk) - } - } + pk: updatedColumns.some((c) => c.is_pk), + }, + }, }; } return n; - }) + }), ); setIsEditTableModalOpen(false); @@ -853,7 +880,6 @@ export default function App() { setEditingNode(null); } - function onAddTableSubmit() { if (!newTableName.trim()) return; const newId = `new_table_${Date.now()}`; @@ -912,7 +938,9 @@ export default function App() { const connectionDsn = dsnInputRef.current?.value.trim() ?? ""; if (!nextConnectionName || !connectionDsn) return; if (!isSupportedConnectionDsn(connectionDsn)) { - setError("Connection DSN must use postgresql://, postgres://, or snowflake:// with a host."); + setError( + "Connection DSN must use postgresql://, postgres://, or snowflake:// with a host.", + ); if (dsnInputRef.current) { dsnInputRef.current.value = ""; } @@ -957,12 +985,7 @@ export default function App() { if (isAuthLoading) { return ( -
+

pg-erd-cloud

Authenticating…

@@ -973,7 +996,9 @@ export default function App() { return (

Authentication required

-

{authError ?? "Sign in before managing database metadata."}

+

+ {authError ?? "Sign in before managing database metadata."} +

); } @@ -994,7 +1019,11 @@ export default function App() { + + {createProjectHint ? ( + + {createProjectHint} + + ) : null} + + +
+ +
+ + -
- + {connections.map((c) => ( + + ))} + + + +
+ + setConnName(e.target.value)} + placeholder="name" + /> + + setIsDsnPresent(Boolean(e.currentTarget.value.trim())) + } + placeholder="postgresql://... or snowflake://..." + aria-label="Connection DSN" + /> + + {createConnectionHint ? ( + + {createConnectionHint} + + ) : null} +
+ +
+ + setSchemaFilter(e.target.value)} + placeholder="public" + /> +
-
- -
- setProjectName(e.target.value)} - /> -
- {createProjectHint ? ( - - {createProjectHint} - - ) : null} -
- -
- -
- - -
- -
- - setConnName(e.target.value)} - placeholder="name" - /> - - setIsDsnPresent(Boolean(e.currentTarget.value.trim())) - } - placeholder="postgresql://... or snowflake://..." - aria-label="Connection DSN" - /> - - {createConnectionHint ? ( - - {createConnectionHint} - - ) : null} -
- -
- - setSchemaFilter(e.target.value)} - placeholder="public" - /> -
- - - {createSnapshotHint ? ( - - {createSnapshotHint} - - ) : null} - -
- Snapshot: {snapshot?.status || "—"} - {snapshot?.error_message ? ( -
- {String(snapshot.error_message)} + {createSnapshotHint ? ( + + {createSnapshotHint} + + ) : null} + +
+ Snapshot: {snapshot?.status || "—"} + {snapshot?.error_message ? ( +
+ {String(snapshot.error_message)} +
+ ) : null}
- ) : null} -
- {error ? ( -
- {error} -
- ) : null} + {error ? ( +
+ {error} +
+ ) : null} ) : (
@@ -1177,7 +1213,10 @@ export default function App() {
{activeView === "dashboard" ? ( -
+

대시보드

@@ -1203,7 +1242,10 @@ export default function App() {
-
+

최근 프로젝트

) : ( -
아직 프로젝트가 없습니다. 편집기에서 프로젝트를 생성하세요.
+
+ 아직 프로젝트가 없습니다. 편집기에서 프로젝트를 생성하세요. +
)}
-
+

최근 다이어그램

-
+
이름 연결 동작
{projects.map((project) => ( -
+
{project.project_name} - {project.project_space_uuid === selectedProjectId ? connections.length : "선택 후 표시"} + + {project.project_space_uuid === selectedProjectId + ? connections.length + : "선택 후 표시"} +
@@ -1306,7 +1370,11 @@ export default function App() {

다이어그램

-

{selectedProject ? `${selectedProject.project_name} 프로젝트의 스냅샷` : "프로젝트를 선택하세요."}

+

+ {selectedProject + ? `${selectedProject.project_name} 프로젝트의 스냅샷` + : "프로젝트를 선택하세요."} +

) : (
-
- - - - - - - - - + + + + + + + + + +
+ {[layoutMessage, nodeSearchStatus].filter(Boolean).join(" ")} +
+
+ + { + reactFlowRef.current = instance; + }} > - IMG - - + + )} +
+ )} + + 0} + shareLinkUrl={shareLinkUrl} + isCreatingShareLink={isCreatingShareLink} + isShareLinkCopied={isShareLinkCopied} + shareLinkError={shareLinkError} + canCreateShareLink={Boolean(selectedProjectId)} + onCloseExport={onCloseExport} + onCopyExportDdl={onCopyExportDdl} + onCreateShareLink={onCreateShareLink} + onCopyShareLink={onCopyShareLink} + /> + + + + + + - UML - - -
- {[layoutMessage, nodeSearchStatus].filter(Boolean).join(" ")} -
-
+ parsePositiveInteger={parsePositiveInteger} + calculateCardinalityRatio={calculateCardinalityRatio} + formatPercent={formatPercent} + strengthLabel={strengthLabel} + /> - { - reactFlowRef.current = instance; - }} - > - - - - - - {nodes.length === 0 && ( -
- {isSnapshotPending ? ( - <> - + + + +
)}
@@ -1608,7 +1690,8 @@ function DiagramTable({ if (!snapshots.length) { return (
- 아직 다이어그램 스냅샷이 없습니다. 편집기에서 데이터베이스를 역공학해 시작하세요. + 아직 다이어그램 스냅샷이 없습니다. 편집기에서 데이터베이스를 역공학해 + 시작하세요.
); } @@ -1622,7 +1705,11 @@ function DiagramTable({ 동작 {snapshots.map((item, index) => ( -
+
ERD_{item.schema_filter || "all"}_{index + 1}