/* * ★ 수 하나가 필요하면 **집계로 묻는다** — `findOne(order: …)` 으로 흉내내지 않는다. * * ── 왜 이 검사가 필요한가 (2026-08-28 전수 조사) ──────────────────────────── * 「가장 늦은 사건」·「저널 머리」·「첫 사건」처럼 **한 줄만** 필요한 자리에서 이런 모양을 썼다. * * findOne({ where: { domain: { id }, … }, order: { revision: 'DESC' } }) * * 관계를 가진 엔티티에서 `find`·`findOne` 은 두 단계 질의가 되고 **안쪽에 상한이 없다** — 조건에 맞는 * 행을 전부 만든 뒤 밖에서 한 줄을 고른다. 이 파일이 지키는 저장소에서 실측한 값이다. * * twinEvents 의 같은 모양 0.024초 대 12초 (저널 375만 행) * 현장 성과(`latestEventTime`) 7.2초 — 업무 사실 97만 행을 payload 까지 만든 뒤 한 줄 * 백테스트 구간(`gapWindow`) 같은 모양을 두 번(첫 사건·마지막 사건) * * **답은 맞고 비용만 틀리다.** 그래서 사람 리뷰가 잡지 못한다 — 기계에 맡긴다. * (같은 이유로 만든 검사가 하나 더 있다: `journal-read-discipline`. 그것은 「통째로 읽는 자리」를 보고, * 이 검사는 「한 줄만 필요한데 통째로 읽는 자리」를 본다.) * * ── 규율 ──────────────────────────────────────────────────────────────────── * 큰 표에서 한 줄·한 수가 필요하면 질의 빌더로 `MIN`·`MAX`·`COUNT` 를 묻는다. 색인의 끝을 집으므로 * 행을 만들지 않는다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' const ROOT = join(import.meta.dirname, '..', 'server') /** 이 검사가 보는 큰 표 — 행이 수백만이 될 수 있는 것들. */ const BIG_TABLES = ['TwinEvent', 'TwinSubjectEvent'] /** * 예외 — **이유와 함께** 적는다. 이유가 없으면 예외가 아니다. * * 지금은 비어 있다. 예외를 더할 때는 왜 한 줄이 아니라 여러 행이 필요한지, 그리고 없어질 조건을 함께 적는다. */ const ALLOWED: { file: string; why: string }[] = [] function sites(): { file: string; line: number; snippet: string }[] { const out: { file: string; line: number; snippet: string }[] = [] const walk = (dir: string) => { for (const name of readdirSync(dir)) { const p = join(dir, name) if (statSync(p).isDirectory()) { walk(p); continue } if (!name.endsWith('.ts')) continue const src = readFileSync(p, 'utf8') /* * 큰 표의 저장소에서 `findOne` 을 부르면서 `order:` 를 주는 모양을 찾는다. 정렬을 준다는 것은 * 「끝에서 한 줄」을 원한다는 뜻이고, 그것이 곧 집계다. * * 변수에 담아 부르는 형태도 본다 — 그러지 않으면 이름만 바꿔 이 검사를 지나칠 수 있다. */ for (const table of BIG_TABLES) { const vars = new Set() for (const m of src.matchAll(new RegExp(`const\\s+(\\w+)\\s*=\\s*getRe(?:ad)?Repository\\(${table}\\)`, 'g'))) { vars.add(m[1]) } const receivers = [`getRepository(${table})`, `getReadRepository(${table})`, ...[...vars]] for (const recv of receivers) { let at = src.indexOf(recv) while (at >= 0) { const after = src.slice(at + recv.length, at + recv.length + 400) const call = /^\s*\n?\s*\.findOne\(\s*\{([\s\S]{0,320})/.exec(after) if (call && /order\s*:/.test(call[1])) { out.push({ file: p.slice(ROOT.length + 1), line: src.slice(0, at).split('\n').length, snippet: call[1].replace(/\s+/g, ' ').slice(0, 90) }) } at = src.indexOf(recv, at + 1) } } } } } walk(ROOT) return out } test('★ 큰 표에서 한 줄을 원하면 집계로 묻는다 — findOne(order) 으로 흉내내지 않는다', () => { const found = sites().filter(s => !ALLOWED.some(a => a.file === s.file)) assert.deepEqual( found.map(s => `${s.file}:${s.line} — ${s.snippet}`), [], '이 모양은 조건에 맞는 행을 전부 만든 뒤 한 줄을 고른다. 질의 빌더로 MIN·MAX·COUNT 를 묻는다.\n' + '정말 여러 행이 필요하면 ALLOWED 에 이유와 없어질 조건을 함께 적는다.' ) }) test('예외 목록에는 반드시 이유가 붙는다 — 이름만 적어 지나가지 않게', () => { for (const a of ALLOWED) assert.ok(a.why.trim().length > 20, `${a.file} 의 예외에 이유가 없다`) }) test('★ 고친 자리들이 실제로 집계를 쓴다 — 되돌아가지 않게 이름으로 못박는다', () => { /* * 이름으로 못박는 이유: 되돌아가도 **답은 맞기 때문에** 결과를 보는 시험으로는 잡히지 않는다. */ const kpi = readFileSync(join(ROOT, 'engine', 'kpi-query.ts'), 'utf8') assert.match(kpi, /select\('MAX\(e\.eventTime\)', 'at'\)/, '마지막 업무 기록 시각은 집계로 묻는다') const engine = readFileSync(join(ROOT, 'engine', 'twin-engine.ts'), 'utf8') assert.match(engine, /select\('MAX\(e\.revision\)', 'max'\)/, '저널 머리는 집계로 묻는다') const forecast = readFileSync(join(ROOT, 'service', 'twin-forecast', 'twin-forecast-query.ts'), 'utf8') assert.match(forecast, /\$\{agg\}\(e\.eventTime\)/, '백테스트 구간의 양 끝은 집계로 묻는다') const model = readFileSync(join(ROOT, 'service', 'twin-model', 'twin-model-query.ts'), 'utf8') assert.match(model, /select\('MAX\(e\.revision\)', 'max'\)/, '모델 요약의 저널 끝도 집계로 묻는다') })