/* * ★ **아무것도 듣지 못한 것을 「비었다」로 적지 않는다** — 체크포인트가 좋은 스냅샷을 덮지 않게. * * ── 무엇이 손실을 영구화하고 있었나 (2026-08-24 실측) ──────────────────────── * 미러가 재기동하면 관측 축(재고·오더·작업)을 들고 오지 않는다(`startLive` 가 되찾은 상태를 버린다 — * 「다음 계측이 정정한다」는 전제). 그런데 원본이 **커서 증분**으로 말하는 현장에서는 그 정정이 오지 * 않는다: 커서가 따라잡힌 뒤 원본이 변하지 않으면 미러는 영구히 빈 채다. * * 실측(포천, `operato-chef-v1-pocheon`): * * 저널을 계산하면 items 741 · orders 2,856 · tasks 9,250 · nowTime 2026-04-15 * 라이브 미러 items 0 · orders 0 · tasks 0 · nowTime 2026-01-01 * 저장된 스냅샷 rev 221,884 · items 741 · orders 2,780 · tasks 6,353 (어제 것) * 그런데 이 버전이 같은 키에 **빈 상태**를 쓰고 있었다 * * 그러면 되살릴 캐시가 사라진다. 재기동이 상태를 버리는 것은 저널에서 되살릴 수 있지만, **캐시를 * 지우는 것은 그 복구 수단을 함께 지운다.** * * ── 왜 리비전으로는 막을 수 없나 ──────────────────────────────────────────── * 저널 줄 번호는 **관측이 없어도 자란다**(다른 축·다른 트윈의 사건, 체크포인트 자체). 그래서 * 「리비전이 더 크면 진전」이라는 규칙은 빈 상태를 통과시킨다. * * 막는 기준은 **「들은 것이 있나」**다. 유입이 한 건도 없었다면 그 빈 상태는 원본이 「비었다」고 * 말한 것이 아니라 **우리가 아무것도 못 들은 것**이다 — 그 둘을 같은 값으로 적으면 「모름」이 * 「없음」이 된다. 이 저장소가 여러 번 거절한 모양이다. * * 원본이 실제로 「다 비었다」고 말한 경우는 막지 않는다 — 그때는 유입이 있었으므로 이 문을 지난다. * * ── 엔진 본체는 부를 수 없다 ──────────────────────────────────────────────── * DB 를 물고 있어 시험에서 부르지 못한다(§`snapshot-freshness` 와 같은 사정). 그래서 판정식만 떼어 * 시험하고, **그 식이 엔진에 실제로 있는지**를 소스로 함께 확인한다 — 식만 시험하면 엔진이 그것을 * 쓰지 않아도 초록이다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { join } from 'node:path' /** 엔진의 조건과 같은 식(`twin-engine.persistSnapshot`). */ const observed = (s: any): number => (s?.items?.length ?? 0) + (s?.orders?.length ?? 0) + (s?.tasks?.length ?? 0) const refuses = (live: any, stored: any, ingestedTotal: number): boolean => !(ingestedTotal > 0) && observed(live) === 0 && observed(stored) > 0 const RICH = { items: [{ epc: 'a' }], orders: [{ id: 'o' }], tasks: [{ id: 't' }] } const EMPTY = { items: [], orders: [], tasks: [] } test('못 들었고 라이브가 비었는데 저장된 것에 사실이 있으면 — **거절한다**', () => { /* ★ 이 시험이 막는 것. 통과시키면 되살릴 유일한 사본이 사라진다. */ assert.equal(refuses(EMPTY, RICH, 0), true) }) test('들은 것이 있으면 통과한다 — 원본이 「다 비었다」고 말한 것은 사실이다', () => { /* * 창고가 실제로 비는 것은 정상이다. 그때는 유입이 있었으므로 그 빈 상태가 **관측**이고, * 여기서 막으면 스냅샷이 영구히 낡는다(고치려던 것의 반대 방향으로 틀린다). */ assert.equal(refuses(EMPTY, RICH, 1), false) assert.equal(refuses(EMPTY, RICH, 24839), false) }) test('라이브에 사실이 있으면 통과한다 — 못 들었어도 심어진 상태는 지킬 값이다', () => { assert.equal(refuses(RICH, RICH, 0), false) }) test('저장된 것이 비어 있으면 막을 것이 없다 — 첫 체크포인트를 가로막지 않는다', () => { /* 처음 뜬 트윈은 저장된 것이 없다. 여기서 막으면 그 트윈은 영원히 스냅샷을 갖지 못한다. */ assert.equal(refuses(EMPTY, EMPTY, 0), false) assert.equal(refuses(EMPTY, null, 0), false) assert.equal(refuses(EMPTY, undefined, 0), false) }) test('축 하나만 남아도 사실이다 — 셋을 합쳐 센다', () => { /* * 오더만 남고 재고가 빈 상태는 흔하다(다 내보낸 창고). 축마다 따로 판정하면 그 상태가 * 「비었다」로 읽힌다. */ assert.equal(refuses(EMPTY, { items: [], orders: [{ id: 'o' }], tasks: [] }, 0), true) assert.equal(refuses(EMPTY, { items: [], orders: [], tasks: [{ id: 't' }] }, 0), true) }) test('리비전은 판정에 쓰지 않는다 — 저널 번호는 관측이 없어도 자란다', () => { /* * ★ 이 단정이 **틀린 기준으로 돌아가는 것**을 막는다. 실측에서 저장된 스냅샷의 리비전은 221,884 * 였고 라이브는 그보다 큰 번호를 들고 있었다 — 리비전을 기준으로 삼으면 빈 상태가 통과한다. */ const live = { ...EMPTY, revision: 999999 } const stored = { ...RICH, revision: 1 } assert.equal(refuses(live, stored, 0), true, '번호가 커도 사실이 없으면 덮지 않는다') }) test('엔진이 실제로 이 판정을 한다 — 식만 시험하면 엔진이 안 써도 초록이다', () => { const src = readFileSync(join(import.meta.dirname, '..', 'server', 'engine', 'twin-engine.ts'), 'utf8') /* 주석을 걷어낸 코드만 — 「덮지 않는다」를 적어 둔 근거가 가드를 뒤집지 않게. */ const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/.*$/gm, '$1') const at = code.indexOf('persistSnapshot') assert.ok(at > 0, 'persistSnapshot 이 있어야 한다') const body = code.slice(at, at + 2000) assert.match(body, /ingestedTotal/, '「들은 것이 있나」를 본다') assert.match(body, /items\?\.length[\s\S]{0,80}orders\?\.length[\s\S]{0,80}tasks\?\.length/, '관측 축 셋을 합쳐 센다') assert.match(body, /checkpoint refused/, '거절을 말한다 — 알리지 않고 거절하지 않는다') assert.doesNotMatch(body.slice(0, body.indexOf('checkpoint refused')), /revision >|revision