/* * ★ **칸이 있으면 쓰는 길도 있어야 한다** — `save` 가 손으로 든 목록이라 새 칸을 빠뜨린다. * * ── 무엇이 있었나 (2026-09-04) ────────────────────────────────────────────── * 일꾼에 네 칸을 더했다 — `dispatch_note_next` · `retry` · `attempts` · `next_attempt_at`. * 엔티티에 선언하고 마이그레이션까지 걸었는데 **`save` 가 그 칸을 안 썼다.** 그 함수는 쓸 칸을 * 손으로 들고 있고, 새 칸이 그 목록에 없었다. * * 그래서 일꾼이 물러섬을 계산해 넘겼는데 표에는 아무것도 안 남았다. 재기동하면 물러서던 것이 * 전부 즉시 재시도가 되고, 목록의 「몇 번 해 봤나」가 영원히 비어 있다. **오류는 나지 않는다.** * * 30분을 뒤진 끝에 찾았고, 이 저장소에서 하루 종일 「자리는 있고 길이 없다」로 부른 것과 같다 — * 이번에는 **칸과 쓰는 길** 사이였다. * * ── 무엇을 검사하나 ──────────────────────────────────────────────────────── * 엔티티가 칸을 가진 필드는 `save` 가 쓰거나, 안 쓰는 이유가 아래 표에 있어야 한다. 목록을 * 소스에서 읽는다 — 손으로 적으면 새 칸이 조용히 빠지고 그것이 바로 이 검사가 막으려는 실패다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' const bare = (p: string): string => readFileSync(new URL(p, import.meta.url), 'utf8') .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\/[^\n]*/g, '') const ENTITY = bare('../server/service/actuation/twin-command.ts') const STORE = bare('../server/service/actuation/command-store.ts') /** * `save` 가 일부러 안 쓰는 칸 — 이유와 함께. * * 비어 있는 것이 정상이 아니다: 이 표는 「고치는 경로가 다른 칸」을 든다. 채울 때 그 이유가 * 「다른 함수가 쓴다」인지 「읽기 전용」인지 적어야 한다. */ const NOT_BY_SAVE: { column: string; why: string }[] = [ { column: 'domain', why: '열쇠 — 테넌트로 좁히는 조건이다' }, { column: 'instanceId', why: '낼 때 정해지고 바뀌지 않는다(create)' }, { column: 'type', why: '낼 때 정해지고 바뀌지 않는다(create)' }, { column: 'payload', why: '낼 때 정해지고 바뀌지 않는다 — 지시 내용은 제안될 때 굳는다' }, { column: 'origin', why: '낼 때 정해지고 바뀌지 않는다(create)' }, { column: 'proposedBy', why: '낼 때 정해지고 바뀌지 않는다(create)' }, { column: 'reversible', why: '명세에서 온다 — 낼 때 굳는다(create)' }, { column: 'causeRef', why: '낼 때 정해진다 — 규칙이 낸 근거(create)' }, { column: 'ruleRef', why: '낼 때 정해진다(create)' }, { column: 'effectStatus', why: '유입 경로가 `recordEffects` 로만 적는다 — 관측이 조치의 수명에 손대지 않게' }, { column: 'effectAt', why: '같음 — `recordEffects`' }, { column: 'effectSeen', why: '같음 — `recordEffects`' }, { column: 'createdAt', why: 'TypeORM 이 넣는다' }, { column: 'updatedAt', why: 'TypeORM 이 넣는다' } ] /** 엔티티가 컬럼을 선언한 속성 이름들 — 데코레이터 다음 줄의 필드를 읽는다. */ function entityColumns(): string[] { const out: string[] = [] const lines = ENTITY.split('\n') for (let i = 0; i < lines.length; i++) { if (!/@(Column|CreateDateColumn|UpdateDateColumn|ManyToOne)\(/.test(lines[i])) continue /* 데코레이터가 여럿 붙는다(@Column + @Field) — 다음 필드 줄까지 내려간다. */ for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) { const m = lines[j].match(/^\s{2}([a-zA-Z][\w]*)\??:/) if (m) { out.push(m[1]) break } } } return [...new Set(out)] } /** `save` 가 실제로 쓰는 속성 — 그 함수 본문에서만 읽는다. */ function savedColumns(): string[] { const start = STORE.indexOf('save: async') assert.ok(start > 0, '`save` 를 못 찾았다 — 이 검사가 따라가야 한다') const body = STORE.slice(start, STORE.indexOf('create: async', start)) return [...new Set([...body.matchAll(/^\s{10}([a-zA-Z][\w]*):/gm)].map(m => m[1]))] } test('★ 엔티티가 든 칸은 save 가 쓰거나 안 쓰는 이유가 적혀 있다', () => { const excused = new Set(NOT_BY_SAVE.map(e => e.column)) const written = new Set(savedColumns()) const orphans = entityColumns().filter(c => !written.has(c) && !excused.has(c)) assert.deepEqual( orphans, [], '엔티티에 칸이 있는데 `save` 가 안 쓴다. 값이 계산되고 표에는 안 남는다 — 오류는 나지 않는다:\n ' + orphans.join('\n ') + '\n쓰게 하거나, 다른 경로로 쓴다면 NOT_BY_SAVE 에 이유를 적는다.' ) }) test('★ 이 검사가 실제로 무언가를 잰다 — 견준 것이 0이면 빨강', () => { /* * 정규식이 엔티티 모양을 못 따라가면 목록이 비고, 그러면 위 검사가 **초록인데 아무것도 안 재는 * 상태**가 된다. 오늘 이 저장소의 다른 검사가 그렇게 났다. */ const columns = entityColumns() const written = savedColumns() assert.ok(columns.length > 15, `엔티티 칸을 ${columns.length}개만 읽었다 — 정규식이 모양을 놓쳤다`) assert.ok(written.length > 5, `save 가 쓰는 칸을 ${written.length}개만 읽었다`) /* 예외 표가 낡지 않았나 — 지워진 칸이 남아 있으면 검사를 넓혀 주는 척만 한다. */ const known = new Set(columns) const stale = NOT_BY_SAVE.map(e => e.column).filter(c => !known.has(c)) assert.deepEqual(stale, [], `예외 표에 있는데 엔티티에 없다:\n ${stale.join('\n ')}`) /* 이유 없는 예외는 예외가 아니다. */ const mute = NOT_BY_SAVE.filter(e => !e.why?.trim()).map(e => e.column) assert.deepEqual(mute, [], `이유 없이 예외로 올렸다:\n ${mute.join('\n ')}`) }) test('★ 일꾼이 쓰는 네 칸이 실제로 save 를 지난다', () => { /* * 위 검사는 목록 대조라 예외 표에 올리면 통과한다. 이 넷은 **물러섬이 표에 남는 근거**여서 * 예외가 될 수 없다 — 이름으로 못 박는다. */ const written = new Set(savedColumns()) for (const c of ['dispatchNoteNext', 'retry', 'attempts', 'nextAttemptAt']) { assert.ok(written.has(c), `save 가 ${c} 를 안 쓴다 — 물러섬이 재기동에 사라진다`) } })