/* * ★ 「원본에 닿지 못한다」와 「연결된 시스템에서 들어온 것이 없다」는 **다른 사실**이다 — 상태가 그것을 가려야 한다. * * ── 무엇이 아무 신호도 내지 않았나 (2026-08-23 실측) ─────────────────────────────────────── * 실 원본이 끊겼다(`jejoup.hatiolab.com:4000` 접속 시간 초과, HTTP 000 / 8초). 그런데 트윈의 * **조회 가능한 상태 어디에도 그 사실이 없었다.** * * recordIngest `onRecords` 가 불릴 때만 도달한다 — 읽기 실패는 그 문에 오지 않는다 * TwinReference.lastError 마스터 동기·접속 시험에만 적힌다 — 라이브 피드는 적지 않았다 * 계약 `openLiveFeed` 에 「읽지 못했다」를 말할 통로가 없었다 * * 어댑터는 **로그로는 말하고 있었다**(재시도 경고). 그러나 로그는 사람이 볼 때만 값이 있다 — 화면이 * 말하려면 상태에 있어야 한다. 실증 데모 중에 원본이 끊기면 사용자가 원인을 찾을 수 없다. * * ── 이 시험이 지키는 것 ───────────────────────────────────────────────────── * ① 실패가 상태에 남는다 ② 「몇 번」과 「언제부터」를 함께 든다(한 번의 흔들림과 20분째 단절은 다르다) * ③ **빈 읽기는 성공이다** — 들어온 것이 없는 연결을 끊긴 원본으로 만들지 않는다 * ④ 조회 뷰로 나간다 — 상태에만 있고 안 나가면 화면이 못 본다 */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { clearReadFailure, ingestHealth, newIngestLedger, recordIngest, recordReadFailure } from '../server/engine/ingest-health.ts' const T0 = Date.parse('2026-08-23T06:00:00.000Z') test('실패가 상태에 남는다 — 사유와 흐름까지', () => { const l = newIngestLedger() assert.equal(l.readFailure, undefined, '처음에는 없다(없음이 「닿는다」는 뜻이다)') recordReadFailure(l, 'connect ETIMEDOUT', T0, 'lot') assert.equal(l.readFailure?.consecutive, 1) assert.equal(l.readFailure?.reason, 'connect ETIMEDOUT') assert.equal(l.readFailure?.stream, 'lot', '어느 표가 막혔나가 원인 찾기의 절반이다') }) test('한 번의 흔들림과 20분째 단절을 가른다 — 시작 시각을 지킨다', () => { /* * 매번 새로 적으면 화면이 늘 「방금 실패했다」만 보게 되고, 계속된 단절을 대수롭지 않게 말한다. */ const l = newIngestLedger() recordReadFailure(l, 'ETIMEDOUT', T0) recordReadFailure(l, 'ETIMEDOUT', T0 + 600_000) recordReadFailure(l, 'ETIMEDOUT', T0 + 1_200_000) assert.equal(l.readFailure?.consecutive, 3) assert.equal(l.readFailure?.sinceMs, T0, '처음 실패한 시각을 지킨다') assert.equal(l.readFailure?.lastAtMs, T0 + 1_200_000, '마지막 실패는 갱신된다') }) test('빈 읽기는 성공이다 — 들어온 것이 없는 연결을 끊긴 원본으로 만들지 않는다', () => { /* * ★ 이 단정이 고치려던 것의 **반대 방향으로 틀리는 것**을 막는다. 원본이 「새 것이 없다」고 답한 것은 * 닿았다는 뜻이다. */ const l = newIngestLedger() recordReadFailure(l, 'ETIMEDOUT', T0) clearReadFailure(l) assert.equal(l.readFailure, undefined, '읽었으면 단절 기록이 사라진다') /* 그리고 그 뒤 다시 실패하면 **새 단절**이다 — 옛 시작 시각을 물려받지 않는다. */ recordReadFailure(l, 'ETIMEDOUT', T0 + 3_600_000) assert.equal(l.readFailure?.consecutive, 1, '연속이 끊겼으므로 1 부터') assert.equal(l.readFailure?.sinceMs, T0 + 3_600_000, '새 단절의 시작이다') }) test('조회 뷰로 나간다 — 상태에만 있으면 화면이 못 본다', () => { const l = newIngestLedger() recordReadFailure(l, 'connect ETIMEDOUT', T0, 'inventory') const v = ingestHealth(l, 'attached', undefined, T0 + 60_000) assert.ok(v.readFailure, '뷰에 실려야 한다') assert.equal(v.readFailure?.consecutive, 1) assert.equal(v.readFailure?.since, new Date(T0).toISOString()) assert.equal(v.readFailure?.reason, 'connect ETIMEDOUT') assert.equal(v.readFailure?.stream, 'inventory') }) test('닿고 있으면 그 필드를 만들지 않는다 — 빈 값이 「닿는다」로 읽히지 않게', () => { const l = newIngestLedger() recordIngest(l, 3, [], T0) const v = ingestHealth(l, 'attached', undefined, T0 + 1000) assert.equal(v.readFailure, undefined) /* 가드가 스스로를 증명한다 — 이 장부가 실제로 무엇을 받았다. */ assert.equal(v.total.offered, 3, '빈 비교로 통과하지 않는다') }) test('「유입 없음」과 「닿지 못함」은 다른 판정이다', () => { /* * 유입 없음은 연결된 시스템에서 들어온 것이 없는 것일 수 있고, 닿지 못함은 우리가 묻지도 못한 것이다. 두 상태가 같은 * 값으로 뭉개지면 화면이 두 문장을 다르게 말할 수 없다. */ const quiet = newIngestLedger() const dark = newIngestLedger() recordReadFailure(dark, 'ETIMEDOUT', T0) const vq = ingestHealth(quiet, 'attached', undefined, T0) const vd = ingestHealth(dark, 'attached', undefined, T0) assert.equal(vq.lastAt, null, '들어온 것이 없는 쪽도 받은 것이 없다') assert.equal(vd.lastAt, null, '끊긴 쪽도 받은 것이 없다 — 그 값만으로는 갈리지 않는다') assert.notEqual(Boolean(vq.readFailure), Boolean(vd.readFailure), '이 축이 그 둘을 가른다') }) /* * ── 읽기 실패가 **피드를 끄면 안 된다** (2026-08-23, 되돌린 자리) ──────────── * * 처음 구현에서 읽기 실패가 레퍼런스 행의 `status` 를 `'error'` 로 적었다. 그것이 **피드를 영구히 * 껐다**: 부팅 재부착은 `status: 'connected'` 인 레퍼런스만 되살리므로, 원본이 **잠깐** 끊긴 것이 * 다음 재기동에서 그 트윈을 아예 굶겼다. 실측으로 그렇게 났다 — 그 버전의 로그에 `[twin-live]` 줄이 * 한 줄도 없었다. * * 두 축을 한 칸에 섞은 것이 원인이다. * * status **연결 설정이 성립하나** — 접속 시험·마스터 동기가 정한다. 재부착의 **자격**이다 * 읽기 실패 **지금 닿나** — 오갈 수 있는 사실이고 유입 장부가 그 축을 갖는다 * * 「사실을 말하게」 하려던 것이 「사실을 못 듣게」 만들었다. 그 방향의 실패는 들어온 것이 없다 — 화면은 * 「원본에 닿지 못한다」를 정직하게 말하면서, 정작 다시 닿을 기회를 잃는다. */ test('읽기 실패가 레퍼런스의 status 를 건드리지 않는다 — 그것이 재부착의 자격이다', () => { const src = readFileSync(join(import.meta.dirname, '..', 'server', 'service', 'reference', 'reference-live.ts'), 'utf8') const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/.*$/gm, '$1') const at = code.indexOf('onReadFailure:') assert.ok(at > 0, 'onReadFailure 배선이 있어야 한다') const body = code.slice(at, at + 600) assert.doesNotMatch(body, /status:/, '읽기 실패는 status 를 적지 않는다 — 적으면 다음 부팅이 그 피드를 되살리지 않는다') /* 대신 이유는 남긴다 — 사람이 목록에서 볼 수 있게. */ assert.match(body, /lastError/, '이유는 레퍼런스 행에 남긴다') /* 그리고 「지금 닿나」는 장부가 답한다. */ assert.match(body, /recordIngestReadFailure/, '유입 장부가 그 축을 든다') }) test('재부착의 자격은 connected 뿐이다 — 그래서 그 값을 함부로 바꾸면 안 된다', () => { /* * 이 단정이 위 가드의 **이유**를 못박는다. 재부착 조건이 넓어지면 위 규율의 근거가 약해지므로, * 둘이 함께 움직여야 한다. */ const src = readFileSync(join(import.meta.dirname, '..', 'server', 'service', 'reference', 'reference-live.ts'), 'utf8') assert.match(src, /find\(\{ where: \{ status: 'connected' \}/, '부팅 재부착은 connected 만 되살린다') })