Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
ed16a2c
⚡ Bolt: Optimize handle decoding logic for iterative ERD exports
seonghobae Aug 1, 2026
72d9127
fix: strictly validate decoded handle IDs
seonghobae Aug 3, 2026
5ddd5fd
test: cover malformed handle decoding
seonghobae Aug 3, 2026
410ad19
⚡ Bolt: Address review - optimize sanitizeHandleId without array allo…
seonghobae Aug 3, 2026
2e72526
Merge branch 'main' into bolt/optimize-handle-lookup-1738382752955126…
opencode-agent[bot] Aug 3, 2026
58641f4
⚡ Bolt: Enforce canonical decoder forms and strict O(1) export lookups
seonghobae Aug 3, 2026
bdfd0b5
fix: reject non-canonical handle encodings
seonghobae Aug 3, 2026
4021cde
test: enforce canonical handle decoding
seonghobae Aug 3, 2026
e639a44
⚡ Bolt: Restore package-lock.json and package.json to main baseline
seonghobae Aug 3, 2026
8477a4f
chore(frontend): restore npm-only dependency baseline
seonghobae Aug 3, 2026
12d6dd3
chore: repair PR 700 search race
seonghobae Aug 3, 2026
9028d33
chore: repair PR 700 search race v2
seonghobae Aug 3, 2026
3ec9f12
chore: repair PR 700 search race v2
seonghobae Aug 3, 2026
1550dd2
docs(frontend): document ERD handle contracts
seonghobae Aug 3, 2026
06a7eae
test(frontend): await diagram data before search assertion; drop one-…
Aug 4, 2026
c180736
Merge branch 'main' into bolt/optimize-handle-lookup-1738382752955126…
seonghobae Aug 4, 2026
54611f6
fix(dbml): decode edge handles and verify columns in exportDbml (port…
Aug 4, 2026
a3413e4
Merge branch 'main' into bolt/optimize-handle-lookup-1738382752955126…
opencode-agent[bot] Aug 4, 2026
9064391
⚡ Bolt: Optimize handle decoding logic for iterative ERD exports
seonghobae Aug 4, 2026
ae24855
Merge branch 'main' into bolt/optimize-handle-lookup-1738382752955126…
opencode-agent[bot] Aug 4, 2026
8c8f9da
chore(frontend): keep canonical npm toolchain
seonghobae Aug 4, 2026
553f687
⚡ Bolt: Acknowledge review
seonghobae Aug 4, 2026
52381ac
fix(deps): remove unrelated frontend lock drift
seonghobae Aug 4, 2026
b6d938f
chore: restore canonical performance notes
seonghobae Aug 4, 2026
cd2b9ed
chore: bump undici to 7.29.0 via overrides to fix OSV CVEs
seonghobae Aug 4, 2026
0730a25
chore(frontend): restore canonical npm manifest
seonghobae Aug 5, 2026
da516c9
chore(frontend): remove unrelated package and test drift
seonghobae Aug 5, 2026
51faf0a
chore(frontend): preserve canonical engineering journal
seonghobae Aug 5, 2026
f280de4
fix(frontend): require canonical ERD handle encoding
seonghobae Aug 5, 2026
84e0f46
test(frontend): reject non-canonical handle chunks
seonghobae Aug 5, 2026
ae290e5
docs(performance): record ERD handle decoding contract
seonghobae Aug 5, 2026
c96a743
docs(changelog): record canonical handle decoding
seonghobae Aug 5, 2026
831b967
fix(frontend): preserve source and target handle roles
seonghobae Aug 5, 2026
d35d74f
test(frontend): cover role-specific handle decoding
seonghobae Aug 5, 2026
f9addfc
fix(frontend): decode only source-role FK handles
seonghobae Aug 5, 2026
6b5466b
ci: finalize role-safe handle export and verify
seonghobae Aug 5, 2026
1cab3b4
ci: fix role-safe export verification workflow
seonghobae Aug 5, 2026
6b3a319
test(frontend): reject wrong-role FK handles
seonghobae Aug 5, 2026
cbf01df
chore: bump undici to 7.29.0 via overrides to fix OSV CVEs
seonghobae Aug 5, 2026
6c1bf32
chore(frontend): restore canonical npm manifest
seonghobae Aug 6, 2026
0a360e8
chore(frontend): remove unrelated package and test drift
seonghobae Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions frontend/src/erd/__tests__/exportDataDictionary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,24 @@ describe('exportDataDictionary', () => {
expect(markdown).toContain('No columns.');
});

it('should ignore invalid handles when computing foreign keys', () => {
const edge = {
id: 'edge1',
source: 'node1',
target: 'node2',
sourceHandle: 'invalid-handle',
} as Edge;

const md = exportDictionaryMarkdown(
[{ id: 'node1', data: { columns: [{ column_name: 'id', data_type: 'int', is_pk: true }] } }] as Node<TableNodeData>[],
[edge]
);

// invalid-handle shouldn't trigger an FK match
expect(md).toContain('| id | int | Y | N | N | | |');
});


it('escapes Markdown table breakers and HTML-like content', () => {
const markdown = exportDictionaryMarkdown(
[
Expand Down
40 changes: 31 additions & 9 deletions frontend/src/erd/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Node, Edge } from '@xyflow/react';
import { normalizeBusinessGroupColor } from './businessGroups';
import type { IndexRecommendation } from './cardinality';
import type { ForeignKeyEdgeData, TableNodeData } from './convert';
import { sourceColumnHandleId, targetColumnHandleId } from './handleUtils';
import { decodeHandleId } from './handleUtils';

export * from './exportDataDictionary';

Expand Down Expand Up @@ -59,6 +59,8 @@ function fkColumnsForEdge(
edge: Edge,
sourceNode: Node<TableNodeData>,
targetNode: Node<TableNodeData>,
sourceNodeColumnNames: Set<string>,
targetNodeColumnNames: Set<string>
): { sourceColumns: string[]; targetColumns: string[] } | null {
const data = edge.data as ForeignKeyEdgeData | undefined;
const sourceColumns = data?.sourceColumns?.filter(Boolean) || [];
Expand All @@ -67,13 +69,19 @@ function fkColumnsForEdge(
return { sourceColumns, targetColumns };
}

const sourceHandleColumn = (sourceNode.data.columns || [])
.find((column) => sourceColumnHandleId(column.column_name) === edge.sourceHandle)
?.column_name;
const targetHandleColumn = (targetNode.data.columns || [])
.find((column) => targetColumnHandleId(column.column_name) === edge.targetHandle)
?.column_name;
if (sourceHandleColumn && targetHandleColumn) {
// ⚡ Bolt: Optimize handle lookup to O(1) by decoding the handle string directly
// and using a precomputed O(1) Set to check for column existence.
const decodedSource = decodeHandleId(edge.sourceHandle);
const sourceHandleColumn = decodedSource !== null && sourceNodeColumnNames.has(decodedSource)
? decodedSource
: undefined;

const decodedTarget = decodeHandleId(edge.targetHandle);
const targetHandleColumn = decodedTarget !== null && targetNodeColumnNames.has(decodedTarget)
? decodedTarget
: undefined;

if (sourceHandleColumn !== undefined && targetHandleColumn !== undefined) {
return { sourceColumns: [sourceHandleColumn], targetColumns: [targetHandleColumn] };
}

Expand All @@ -96,8 +104,16 @@ export function exportDDL(nodes: Node<TableNodeData>[], edges: Edge[]): string {
// Bolt: Use map for O(1) node lookup instead of O(N) array find
// Avoid Map(array.map) to prevent O(N) intermediate tuple array allocation overhead
const nodesById = new Map<string, Node<TableNodeData>>();
const nodeColumnSets = new Map<string, Set<string>>();
for (const n of nodes) {
nodesById.set(n.id, n);
const colSet = new Set<string>();
if (n.data.columns) {
for (const c of n.data.columns) {
colSet.add(c.column_name);
}
}
nodeColumnSets.set(n.id, colSet);
}

// Export tables
Expand Down Expand Up @@ -133,7 +149,13 @@ export function exportDDL(nodes: Node<TableNodeData>[], edges: Edge[]): string {
const targetNode = nodesById.get(edge.target);

if (sourceNode && targetNode) {
const fkColumns = fkColumnsForEdge(edge, sourceNode, targetNode);
const fkColumns = fkColumnsForEdge(
edge,
sourceNode,
targetNode,
nodeColumnSets.get(sourceNode.id)!,
nodeColumnSets.get(targetNode.id)!
);
const constraintName = edge.label ? edge.label : `fk_${edge.source}_${edge.target}`;
const sourceTable = quoteSqlIdentifier(sourceNode.data.title || sourceNode.id);
const targetTable = quoteSqlIdentifier(targetNode.data.title || targetNode.id);
Expand Down
19 changes: 9 additions & 10 deletions frontend/src/erd/exportDataDictionary.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Edge, Node } from '@xyflow/react';

import type { ForeignKeyEdgeData, TableNodeData } from './convert';
import { sourceColumnHandleId } from './handleUtils';
import { decodeHandleId } from './handleUtils';

const CONTROL_TEXT_RE = /[\u0000-\u001f\u007f]+/g;
const CSV_FORMULA_RE = /^[=+\-@]/;
Expand Down Expand Up @@ -41,7 +41,6 @@ function sourceColumnsForEdge(edge: Edge): Set<string> {

type ForeignKeyNodeInfo = {
columns: Set<string>;
handles: Set<string>;
};

function foreignKeyColumnsByNode(edges: Edge[]): Map<string, ForeignKeyNodeInfo> {
Expand All @@ -50,16 +49,21 @@ function foreignKeyColumnsByNode(edges: Edge[]): Map<string, ForeignKeyNodeInfo>
for (const edge of edges) {
let info = map.get(edge.source);
if (!info) {
info = { columns: new Set<string>(), handles: new Set<string>() };
info = { columns: new Set<string>() };
map.set(edge.source, info);
}

for (const column of sourceColumnsForEdge(edge)) {
info.columns.add(column);
}

// ⚡ Bolt: Directly decode the handle and store the column name, avoiding
// repeatedly encoding every column during the export loop later.
if (edge.sourceHandle) {
info.handles.add(edge.sourceHandle);
const decodedColumn = decodeHandleId(edge.sourceHandle);
if (decodedColumn !== null) {
info.columns.add(decodedColumn);
}
}
}

Expand All @@ -74,12 +78,7 @@ function isForeignKeyColumn(
const info = edgeColumnsByNode.get(node.id);
if (!info) return false;

if (info.columns.has(columnName)) {
return true;
}

const handleId = sourceColumnHandleId(columnName);
return info.handles.has(handleId);
return info.columns.has(columnName);
}

function exampleValue(value: TableNodeData['columns'][number]['example_value']): string {
Expand Down
50 changes: 49 additions & 1 deletion frontend/src/erd/handleUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { sanitizeHandleId, sourceColumnHandleId, targetColumnHandleId } from './handleUtils';
import { sanitizeHandleId, sourceColumnHandleId, targetColumnHandleId, decodeHandleId } from './handleUtils';

describe('handleUtils', () => {
describe('sanitizeHandleId', () => {
Expand Down Expand Up @@ -35,4 +35,52 @@ describe('handleUtils', () => {
expect(targetColumnHandleId('id')).toBe('tgt-c-0069-0064');
});
});

describe('decodeHandleId', () => {
it('should decode a simple ascii string', () => {
expect(decodeHandleId('c-0069-0064')).toBe('id');
expect(decodeHandleId('src-c-0069-0064')).toBe('id');
expect(decodeHandleId('tgt-c-0069-0064')).toBe('id');
});

it('should handle empty string', () => {
expect(decodeHandleId('c-empty')).toBe('');
expect(decodeHandleId('src-c-empty')).toBe('');
});

it('should handle special characters', () => {
expect(decodeHandleId('c-0075-0073-0065-0072-005f-0069-0064')).toBe('user_id');
});

it('should handle unicode characters', () => {
expect(decodeHandleId('c-0069-0064-005f-ac00')).toBe('id_가');
});

it('should handle emojis', () => {
expect(decodeHandleId('c-0069-0064-005f-1f680')).toBe('id_🚀');
});

it('should handle null/undefined/invalid values gracefully', () => {
expect(decodeHandleId(null)).toBeNull();
expect(decodeHandleId(undefined)).toBeNull();
expect(decodeHandleId('')).toBeNull();
expect(decodeHandleId('invalid-format')).toBeNull();
expect(decodeHandleId('src-c')).toBeNull(); // Missing hex parts
});

it('should reject non-canonical hex and prefixes', () => {
// Uppercase hex
expect(decodeHandleId('c-0069-006A')).toBeNull();
// Junk in hex
expect(decodeHandleId('c-0069junk-0064')).toBeNull();
// Arbitrary prefix
expect(decodeHandleId('foo-c-0069')).toBeNull();
// Empty chunk mix
expect(decodeHandleId('c-empty-0069')).toBeNull();
// Out of bounds code point
expect(decodeHandleId('c-200000')).toBeNull();
// Missing hex chunk data between separators
expect(decodeHandleId('c-0069--0064')).toBeNull();
});
});
});
60 changes: 55 additions & 5 deletions frontend/src/erd/handleUtils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
export function sanitizeHandleId(columnName: string): string {
const encoded = Array.from(columnName, (char) => {
// Array.from only yields non-empty Unicode scalars, so codePointAt(0) is defined.
return char.codePointAt(0)!.toString(16).padStart(4, '0')
}).join('-')
if (!columnName) return 'c-empty'

return `c-${encoded || 'empty'}`
let encoded = ''
let isFirst = true
for (const char of columnName) {
if (!isFirst) {
encoded += '-'
}
encoded += char.codePointAt(0)!.toString(16).padStart(4, '0')
isFirst = false
}

return `c-${encoded}`
}

export function sourceColumnHandleId(columnName: string): string {
Expand All @@ -14,3 +21,46 @@ export function sourceColumnHandleId(columnName: string): string {
export function targetColumnHandleId(columnName: string): string {
return `tgt-${sanitizeHandleId(columnName)}`
}

const HEX_CHUNK_RE = /^[0-9a-f]{4,6}$/

export function decodeHandleId(handleId: string | null | undefined): string | null {
if (!handleId) return null;

const parts = handleId.split('-');
let payloadIndex = -1;

if (parts[0] === 'c') {
payloadIndex = 1;
} else if ((parts[0] === 'src' || parts[0] === 'tgt') && parts[1] === 'c') {
payloadIndex = 2;
}

if (payloadIndex === -1) return null;

if (parts.length === payloadIndex + 1 && parts[payloadIndex] === 'empty') {
return '';
}

const hexParts = parts.slice(payloadIndex);
if (hexParts.length === 0) return null;

let decoded = '';
for (const hex of hexParts) {
if (!HEX_CHUNK_RE.test(hex)) {
return null;
}
const codePoint = Number.parseInt(hex, 16);
if (codePoint > 0x10ffff) {
return null;
}
try {
decoded += String.fromCodePoint(codePoint);
} catch {
/* v8 ignore next */
return null;
}
}

return decoded;
}
Loading