Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## Unreleased
- [FE] ⚡ **검색 노드 참조 안정화 및 순차 스냅샷 폴링**: 같은 정규화 검색어와 원본 테이블 데이터에는 장식된 `node.data` 참조를 재사용하여 드래그 중 불필요한 하위 렌더링과 할당을 줄입니다. 스냅샷 폴링은 이전 요청이 끝난 뒤에만 다음 요청을 예약하며, 선택 변경·언마운트 후 도착한 오래된 성공 또는 실패 응답을 무시합니다.
- [BE] 🔒 **공유 export 전 경로 redaction**: 공개 share의 SQL / index-design / reversing-spec export에서 코멘트·`example_value`를 제거합니다. 단위 테스트로 누출을 차단합니다.
- [BE] 🛠️ **함수 인덱스 중복 오탐 수정**: `lower(email)` 등 expression index를 평문 컬럼 인덱스의 중복으로 잘못 판단하지 않도록 괄호 파서를 강화했습니다.
- [Docs] README를 상용 기준 기능 설명으로 갱신 (MVP skeleton 표현 제거, share redaction·diff/export 반영).
Expand Down
39 changes: 39 additions & 0 deletions docs/doctoring/search-identity-and-sequential-polling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Search identity and sequential snapshot polling

## Decision

The ERD canvas treats the immutable `TableNodeData` object as the identity of the source table payload. While a normalized search query is active, a query-scoped `WeakMap<TableNodeData, TableNodeData>` stores the derived highlight/dim payload. Re-rendering with the same source object reuses the exact derived reference; changing the normalized query replaces the cache, and replacing the source payload produces a new derived object.

Snapshot status polling is one sequential asynchronous process per active `(selectedProjectId, snapshotId)` effect. The first request starts immediately. A non-terminal response schedules one `setTimeout` only after the request completes. Terminal states stop polling and may refresh the snapshot list. Cleanup marks the process obsolete and clears the pending timeout, so late success, refresh, or rejection continuations cannot update the current view.

## Why

React Flow can emit frequent position-only node updates. Reallocating every derived `node.data` object during those updates defeats reference-sensitive memoization below the canvas and creates garbage unrelated to actual table-content changes. `WeakMap` keys use object identity and do not keep otherwise unreachable key objects alive, which fits a cache whose lifetime follows the source payload.

A fixed `setInterval` can start another request while the prior request is unresolved. Network responses are not guaranteed to complete in issue order, so an older non-terminal result can overwrite a newer terminal result. React's effect guidance explicitly recommends cleanup-scoped invalidation for manually fetched data because responses may arrive out of order. Completion-scheduled `setTimeout` polling additionally guarantees at most one in-flight status request per effect generation.

## Invariants

- The source `node.data` object is never mutated with search-only state.
- Equivalent normalized queries and position-only updates reuse the derived data reference.
- A changed query or changed source data object yields a fresh derived reference.
- At most one `getSnapshot` call is in flight for one effect generation.
- `succeeded`, `failed`, and `not_found` stop future status requests.
- Dependency change or unmount invalidates all pending continuations before they can publish snapshot, list, or error state.
- Polling errors remain visible only for the still-current snapshot process.

## Verification

`frontend/src/App.searchPolling.test.tsx` observes the `ReactFlow` node payload rather than implementation internals. It asserts reference identity with `toBe`, drives a position-only update and a source-data replacement, exercises reversed response order, rejects a superseded request with sensitive detail, and uses controlled timers to prove non-overlap and terminal shutdown. Repository CI remains authoritative for npm-only type checking, complete statement/branch/function/line coverage, and the production build.

## Operational monitoring and rollback

Monitor snapshot-status request concurrency, terminal-to-render latency, stale-response suppression, browser memory growth during long search/drag sessions, and frontend error rates. Roll back by restoring the prior uncached derivation and interval loop only if a verified regression requires it; doing so reopens the documented allocation and race risks and therefore requires a replacement isolation design and regression evidence.

## References

MDN Web Docs contributors. (2026). *WeakMap*. Mozilla. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap

React Team. (n.d.). *useEffect*. React. Retrieved August 7, 2026, from https://react.dev/reference/react/useEffect

Web Hypertext Application Technology Working Group. (2026, July 13). *HTML Standard: Timers*. https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers
290 changes: 290 additions & 0 deletions frontend/src/App.searchPolling.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
import '@testing-library/jest-dom/vitest'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const api = vi.hoisted(() => ({
getMe: vi.fn(),
listProjects: vi.fn(),
listConnections: vi.fn(),
listSnapshots: vi.fn(),
createProject: vi.fn(),
createConnection: vi.fn(),
createSnapshot: vi.fn(),
getSnapshot: vi.fn(),
createShareLink: vi.fn(),
}))

type CapturedNode = {
id: string
position: { x: number; y: number }
data: Record<string, unknown>
}

const flowCapture = vi.hoisted(() => ({
renders: [] as CapturedNode[][],
setNodes: undefined as
| ((update: CapturedNode[] | ((current: CapturedNode[]) => CapturedNode[])) => void)
| undefined,
}))

vi.mock('./api', () => api)
vi.mock('./erd/TableNode', () => ({ default: () => null }))
vi.mock('./erd/export', () => ({
downloadText: vi.fn(),
exportDDL: vi.fn(() => ''),
exportDiagramSvg: vi.fn(() => ''),
exportDictionaryCsv: vi.fn(() => ''),
exportDictionaryMarkdown: vi.fn(() => ''),
exportPlantUml: vi.fn(() => ''),
}))
vi.mock('./erd/mermaid', () => ({ exportMermaid: vi.fn(() => '') }))
vi.mock('./erd/dbml', () => ({ exportDbml: vi.fn(() => '') }))
vi.mock('./erd/prisma', () => ({ exportPrisma: vi.fn(() => '') }))
vi.mock('./erd/autoInfer', () => ({ inferRelationships: vi.fn(() => []) }))
vi.mock('./components/modals', () => ({
AddTableModal: () => null,
CardinalityModal: () => null,
EditEdgeModal: () => null,
EditTableModal: () => null,
ExportModal: () => null,
GroupModal: () => null,
}))

vi.mock('@xyflow/react', async () => {
const React = await import('react')
return {
Background: () => null,
Controls: () => null,
MiniMap: () => null,
ReactFlow: (props: { nodes: CapturedNode[]; children?: React.ReactNode }) => {
flowCapture.renders.push(props.nodes)
return (
<div data-testid="react-flow">
{props.nodes.map((node) => <span key={node.id}>{String(node.data.title)}</span>)}
{props.children}
</div>
)
},
addEdge: (edge: unknown, edges: unknown[]) => [...edges, edge],
useNodesState: (initial: CapturedNode[]) => {
const [nodes, setNodes] = React.useState(initial)
flowCapture.setNodes = setNodes as typeof flowCapture.setNodes
return [nodes, setNodes, vi.fn()]
},
useEdgesState: (initial: unknown[]) => {
const [edges, setEdges] = React.useState(initial)
return [edges, setEdges, vi.fn()]
},
}
})

const graphData = vi.hoisted(() => ({
firstUsers: {
title: 'public.users',
columns: [{ column_name: 'id', data_type: 'bigint', is_not_null: true, is_pk: true }],
badges: { pk: true, fk: false },
},
firstOrders: {
title: 'public.orders',
columns: [{ column_name: 'user_id', data_type: 'bigint', is_not_null: true, is_pk: false }],
badges: { pk: false, fk: true },
},
secondAccounts: {
title: 'public.accounts',
columns: [{ column_name: 'account_id', data_type: 'bigint', is_not_null: true, is_pk: true }],
badges: { pk: true, fk: false },
},
}))

vi.mock('./erd/convert', () => ({
snapshotToGraph: vi.fn((snapshotJson: { marker?: string }) => snapshotJson.marker === 'second'
? { nodes: [{ id: 'accounts', type: 'tableNode', position: { x: 0, y: 0 }, data: graphData.secondAccounts }], edges: [] }
: {
nodes: [
{ id: 'users', type: 'tableNode', position: { x: 0, y: 0 }, data: graphData.firstUsers },
{ id: 'orders', type: 'tableNode', position: { x: 200, y: 0 }, data: graphData.firstOrders },
],
edges: [],
}),
}))

import App from './App'

const projects = [{ project_space_uuid: 'project-one', project_name: 'Project One' }]
const snapshots = [
{ schema_snapshot_uuid: 'snapshot-one', status: 'running', schema_filter: null },
{ schema_snapshot_uuid: 'snapshot-two', status: 'succeeded', schema_filter: null },
]

function detail(id: string, marker: string, status = 'succeeded') {
return {
schema_snapshot_uuid: id,
status,
schema_filter: null,
error_message: null,
snapshot_json: { marker, relations: [], columns: [], pk_columns: [], fk_edges: [] },
}
}

async function renderReadyApp() {
render(<App />)
await waitFor(() => expect(api.listSnapshots).toHaveBeenCalledWith('project-one'))
}

async function diagramOpenButtons() {
fireEvent.click(screen.getByRole('button', { name: '다이어그램' }))
return screen.findAllByRole('button', { name: '열기' })
}

async function openSnapshot(index: number) {
const openButtons = await diagramOpenButtons()
fireEvent.click(openButtons[index]!)
await waitFor(() => expect(api.getSnapshot).toHaveBeenCalled())
}

function lastNodeData(nodeId: string): Record<string, unknown> {
const data = flowCapture.renders.at(-1)?.find((node) => node.id === nodeId)?.data
if (!data) throw new Error(`No rendered data captured for ${nodeId}`)
return data
}

describe('App search identity and polling isolation', () => {
beforeEach(() => {
vi.clearAllMocks()
flowCapture.renders.length = 0
flowCapture.setNodes = undefined
api.getMe.mockResolvedValue({ subject: 'user-one', display_name: 'User One' })
api.listProjects.mockResolvedValue(projects)
api.listConnections.mockResolvedValue([])
api.listSnapshots.mockResolvedValue(snapshots)
api.createShareLink.mockResolvedValue({ url: 'https://example.test/share' })
})

afterEach(() => {
vi.useRealTimers()
cleanup()
})

it('preserves decorated data identity for normalized-query and position-only updates', async () => {
api.getSnapshot.mockResolvedValue(detail('snapshot-one', 'first'))
await renderReadyApp()
await openSnapshot(0)
await screen.findByText('public.users')

const search = screen.getByLabelText('테이블 또는 컬럼 검색')
fireEvent.change(search, { target: { value: 'users' } })
await waitFor(() => expect(lastNodeData('users').isHighlighted).toBe(true))
const firstDecorated = lastNodeData('users')

fireEvent.change(search, { target: { value: ' users ' } })
await waitFor(() => expect(search).toHaveValue(' users '))
expect(lastNodeData('users')).toBe(firstDecorated)

await act(async () => {
flowCapture.setNodes?.((current) => current.map((node) => (
node.id === 'users'
? { ...node, position: { x: node.position.x + 25, y: node.position.y } }
: node
)))
})
expect(lastNodeData('users')).toBe(firstDecorated)

await act(async () => {
flowCapture.setNodes?.((current) => current.map((node) => (
node.id === 'users' ? { ...node, data: { ...node.data } } : node
)))
})
const replacedSourceData = lastNodeData('users')
expect(replacedSourceData).not.toBe(firstDecorated)

fireEvent.change(search, { target: { value: 'orders' } })
await waitFor(() => expect(lastNodeData('orders').isHighlighted).toBe(true))
expect(lastNodeData('users')).not.toBe(replacedSourceData)
expect(graphData.firstUsers).not.toHaveProperty('isHighlighted')
})

it('ignores a terminal response from a superseded snapshot request', async () => {
let resolveFirst!: (value: ReturnType<typeof detail>) => void
api.getSnapshot.mockImplementation((snapshotId: string) => {
if (snapshotId === 'snapshot-one') {
return new Promise((resolve) => { resolveFirst = resolve })
}
return Promise.resolve(detail('snapshot-two', 'second'))
})

await renderReadyApp()
await openSnapshot(0)
await waitFor(() => expect(api.getSnapshot).toHaveBeenCalledWith('snapshot-one'))

await openSnapshot(1)
await screen.findByText('public.accounts')
const refreshCountAfterCurrentTerminal = api.listSnapshots.mock.calls.length

await act(async () => {
resolveFirst(detail('snapshot-one', 'first'))
await Promise.resolve()
})

expect(screen.getByText('public.accounts')).toBeInTheDocument()
expect(screen.queryByText('public.users')).not.toBeInTheDocument()
expect(api.listSnapshots).toHaveBeenCalledTimes(refreshCountAfterCurrentTerminal)
})

it('does not publish a stale polling rejection after the selected snapshot changes', async () => {
let rejectFirst!: (reason: Error) => void
api.getSnapshot.mockImplementation((snapshotId: string) => {
if (snapshotId === 'snapshot-one') {
return new Promise((_, reject) => { rejectFirst = reject })
}
return Promise.resolve(detail('snapshot-two', 'second'))
})

await renderReadyApp()
await openSnapshot(0)
await openSnapshot(1)
await screen.findByText('public.accounts')

await act(async () => {
rejectFirst(new Error('stale polling failure with secret detail'))
await Promise.resolve()
})

expect(screen.getByText('public.accounts')).toBeInTheDocument()
expect(screen.queryByText(/stale polling failure with secret detail/i)).not.toBeInTheDocument()
})

it('waits for each non-terminal request before scheduling the next poll', async () => {
let resolveFirst!: (value: ReturnType<typeof detail>) => void
let resolveSecond!: (value: ReturnType<typeof detail>) => void
api.getSnapshot
.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))

await renderReadyApp()
const openButtons = await diagramOpenButtons()
vi.useFakeTimers()

fireEvent.click(openButtons[0]!)
await act(async () => { await Promise.resolve() })
expect(api.getSnapshot).toHaveBeenCalledTimes(1)

await act(async () => { await vi.advanceTimersByTimeAsync(2000) })
expect(api.getSnapshot).toHaveBeenCalledTimes(1)

await act(async () => {
resolveFirst(detail('snapshot-one', 'first', 'running'))
await Promise.resolve()
})
await act(async () => { await vi.advanceTimersByTimeAsync(999) })
expect(api.getSnapshot).toHaveBeenCalledTimes(1)
await act(async () => { await vi.advanceTimersByTimeAsync(1) })
expect(api.getSnapshot).toHaveBeenCalledTimes(2)

await act(async () => {
resolveSecond(detail('snapshot-one', 'first'))
await Promise.resolve()
})
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
expect(api.getSnapshot).toHaveBeenCalledTimes(2)
})
})
Loading
Loading