/* * ★ 리비전은 **두 셈**이고, 둘이 같은 것을 세지 않는다 — 그 사실을 코드가 지키는지 본다. * * ── 무엇이 어긋나 있었나 (2026-08-23 실측) ────────────────────────────────── * 포천 미러의 체크포인트가 뜻이 다른 두 수를 나란히 적었다. * * 바깥 revision 12,972 저널에 적힌 줄 번호 * state.revision 6,478 커널이 처리한 봉투 수 * * 차이 6,494 는 그 트윈을 **다시 세운 시각**(09:11)에 저널이 이미 갖고 있던 줄 수와 정확히 같았다 * (줄 6494 = 08:23:59 · 줄 6495 = 09:11:13). 산수가 맞았으니 결함은 「수가 틀렸다」가 아니다. * * 결함은 **비대칭**이었다. 재기동 경로(`start` → `resumeRevision`)는 커널에 저널 머리를 알려 주는데 * 미러를 세우는 경로(`startLive`)는 알려 주지 않았다. 그래서 같은 저장물을 읽는 사람이 같은 이름의 두 * 수를 같은 축으로 견주게 되고, 실제로 그렇게 읽혔다(반나절이 그 오독에서 나갔다). * * ── 왜 알려 주어야 하나 ───────────────────────────────────────────────────── * 커널의 계약이 그 이유를 적어 두었다(`resumeRevision`): 「저널이 비어 있지 않은데 0부터 다시 세면 같은 * 트윈에 같은 리비전이 둘 생기고, 재생 순서가 뒤섞이며, 시간여행이 엉뚱한 시점을 답한다 — 오류는 안 난다.」 * 알리지 않고 깨지는 종류이므로 시험이 지킨다. * * ── 이 시험이 무엇을 재는가 ───────────────────────────────────────────────── * DB 도 커널도 띄우지 않는다. 이 규율은 **어느 경로가 무엇을 부르나**에 있고 그것은 소스에서 확인된다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { join } from 'node:path' const ENGINE = join(import.meta.dirname, '..', 'server', 'engine', 'twin-engine.ts') const src = readFileSync(ENGINE, 'utf8') /** * 그 함수 본문만 — 다른 경로의 호출이 섞여 판정이 흐려지지 않게. * * 끝은 **다음 멤버 선언**으로 자른다. 글자 수로 자르면 주석이 늘어난 날 알리지 않고 짧아져서, 있는 것을 * 없다고 말한다(거짓 빨강을 내는 가드는 없는 것보다 나쁘다). */ function bodyOf(signature: string): string { const at = src.indexOf(signature) assert.ok(at > 0, `${signature} 이 있어야 한다`) const rest = src.slice(at + signature.length) const end = rest.search(/\n (?:static|private static|\/\*\*)/) const body = end === -1 ? rest : rest.slice(0, end) /* 잘라낸 본문이 비면 판정이 성립하지 않는다 — 그것을 통과로 읽지 않는다. */ assert.ok(body.length > 200, `${signature} 본문을 잘라내지 못했다(${body.length}자)`) return body } test('미러를 세우는 경로도 커널에 저널 머리를 알려 준다', () => { const body = bodyOf('static startLive(') /* * 저널 머리를 읽는다 — 이 조회는 예전부터 있었다(inst.revision 시드용). * * 2026-08-28: 모양을 `findOne(order: revision DESC)` 에서 `MAX(e.revision)` 으로 바꿨다. 앞의 것은 * 관계로 물어 두 단계 질의가 되고 안쪽에 상한이 없어 그 트윈의 저널을 전부 만든 뒤 한 줄을 고른다. * **묻는 것은 같다** — 저널 머리다. 그래서 이 단정은 「머리를 읽는가」로 남고 모양만 따라간다. */ assert.match(body, /select\('MAX\(e\.revision\)'/, '저널 머리를 읽어야 한다') /* 그리고 **커널에도** 알려 준다 — 이 한 줄이 없어서 두 수가 갈렸다. */ assert.match(body, /resumeRevision\(head\)/, '커널에도 그 번호를 알려 준다') }) test('재기동 경로는 예전부터 알려 주고 있었다 — 두 경로가 같아졌는지 본다', () => { /* * 이 단정은 「고쳤다」가 아니라 **두 경로가 대칭인지**를 지킨다. 한쪽만 남으면 같은 결함이 다른 * 문으로 되살아난다. */ const body = bodyOf('static start(') assert.match(body, /resumeRevision\(resumeFrom\)/, '재기동 경로도 알려 준다') }) test('저널이 비어 있으면 알려 주지 않는다 — 새 트윈은 0 부터 세는 것이 맞다', () => { const body = bodyOf('static startLive(') assert.match(body, /if \(!head \|\| typeof/, '머리가 0 이면 아무 일도 하지 않는다') }) test('뒤로 되돌리려는 거절을 삼키지 않는다', () => { /* * 조회가 비동기라 그 사이에 이벤트가 들어와 있을 수 있다. 그때 커널은 뒤로 가기를 거절한다(겹치는 * 번호를 막는 그 판정이다). 그 거절을 알리지 않고 버리면 「왜 두 수가 아직 다르지」를 답할 계기가 없다. */ const body = bodyOf('static startLive(') assert.match(body, /catch \(err[^)]*\)[\s\S]{0,200}twinWarn/, '거절을 로그에 남긴다') assert.match(body, /is behind the kernel/, '무엇이 어긋났는지 말한다') }) test('체크포인트는 두 수를 나란히 적는다 — 그 사실 자체는 옳다', () => { /* * 바깥은 저널 줄 번호(이어 쓸 자리), 안쪽은 커널이 계산한 봉투 수다. **둘을 하나로 합치지 않는다** — * 합치면 「어디까지 적었나」와 「어디까지 반영했나」를 구별할 수 없고, 그 구별이 미러의 정직성이다. * 두 경로가 대칭이면 값이 같아지지만, **자리는 둘이어야 한다.** */ const at = src.indexOf('static async persistSnapshot') assert.ok(at > 0, 'persistSnapshot 이 있어야 한다') /* * 창을 넓혀 왔다 — 이 함수 앞쪽에 「빈 상태로 좋은 스냅샷을 덮지 않는다」 가드가 들어오고(§ * `checkpoint-refuses-empty`), 뒤쪽에 「이어 계산할 씨앗을 함께 적는다」가 들어왔다(§ * `mirror-resumes-from-checkpoint`). 검사하는 **사실은 그대로**다. * * 그리고 **줄 모양이 아니라 두 자리를 검사한다**: 예전에는 `{ revision, state` 라는 한 줄 모양을 * 봤는데, 저장 인자가 여러 줄로 나뉘자 사실이 그대로인데도 빨강이 됐다. 서식이 검사를 깨면 다음 * 사람이 검사를 지우게 된다 — 그래서 이름 둘이 함께 실리는지만 본다. */ const body = src.slice(at, at + 5000) assert.match(body, /inst\.revision \?\? state\.revision/, '바깥은 저널 줄 번호를 먼저 쓴다') const call = body.slice(body.indexOf('setInCache')) assert.match(call, /revision/, '저널 줄 번호를 싣는다') assert.match(call, /state/, '커널이 계산한 상태를 함께 싣는다 — 두 자리를 하나로 합치지 않는다') })