/* * ★ 읽기 커서는 **부르는 쪽이 자리를 가리켜야** 이어진다 — 커넥터는 그 이름을 알 수 없다. * * ── 무엇이 알리지 않고 끊겨 있었나 (2026-08-23) ────────────────────────────────── * 커서를 저장할 컬럼과 계약을 만들고 배선까지 했는데, **한 번도 저장되지 않았다.** 저장할 자리를 * `cfg`·`site` 에서 찾으려 했기 때문이다. * * cfg 사람이 화면에서 입력한 접속 설정 — endpoint · token · subdomain · liveLookbackDays * site 커넥터가 원본에서 발견한 것 — { siteId, name } * * **둘 다 그 값을 가질 수 없다.** 레퍼런스의 이름(`TwinReference.source`)은 **호스트가 붙이는 것**이고 * 원본은 그것을 모른다. 커넥터가 채울 수 없는 값을 커넥터에서 읽으려 한 것이 잘못이었다. * * 그리고 그 실패는 **로그로는 말했다** — 「this feed does not say which reference it reads from」. * 그 한 줄로 다른 레인이 바로 짚었다(알리지 않고 넘기지 않은 것이 값을 했다). 그러나 로그는 사람이 볼 때만 * 값이 있다. 배선이 다시 끊기면 이 시험이 먼저 말한다. * * ── 왜 「늘었나」로 확인하면 안 되나 ──────────────────────────────────────── * 재기동 뒤 수가 늘어도 그것은 커서가 이어진 증거가 아니다 — 실제로 같은 버전에 다른 변경(공정별 전이)이 * 들어가 작업 수가 배로 늘었고, 그 사이 커서는 여전히 매번 새로 만들어졌다. 확인할 것은 **배선**이다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { join } from 'node:path' const DIR = join(import.meta.dirname, '..', 'server', 'service', 'reference') const LIVE = readFileSync(join(DIR, 'reference-live.ts'), 'utf8') const RESOLVER = readFileSync(join(DIR, 'reference-resolver.ts'), 'utf8') /** 주석을 걷어낸 코드만 — 「쓰지 않는다」를 적어 둔 근거가 가드를 뒤집지 않게. */ function codeOf(src: string): string { return src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/.*$/gm, '$1') } test('커서를 저장할 자리는 부르는 쪽이 가리킨다 — cfg·site 에서 짐작하지 않는다', () => { const code = codeOf(LIVE) /* 커넥터가 채울 수 없는 값을 커넥터의 인자에서 읽지 않는다. */ assert.doesNotMatch(code, /cfg as any\)\?\.source/, 'cfg 에서 레퍼런스 이름을 찾지 않는다') assert.doesNotMatch(code, /site as any\)\?\.source/, 'site 에서 레퍼런스 이름을 찾지 않는다') /* 대신 인자로 받는다. */ assert.match(code, /loadLiveCursor\(domainId, instanceId, source\)/, '받은 이름으로 커서를 찾는다') /* 가드가 스스로를 증명한다 — 주석을 걷어낸 코드가 비어 버리지 않았다. */ assert.match(code, /startReferenceLiveFeed/, '대상 함수가 남아 있다') }) test('모든 호출처가 그 이름을 넘긴다 — 한 곳만 빠져도 그 경로는 알리지 않고 창이 미끄러진다', () => { /* * 호출처가 둘이고 **둘 다** `TwinReference` 를 들고 있다(부팅 재개 · 인제스트 직후 기동). * 하나만 넘기면 그 경로로 세워진 트윈만 커서를 잃고, 그 차이는 오류 없이 들어온 것이 없다. */ const calls = [...codeOf(LIVE).matchAll(/startReferenceLiveFeed\(([^)]*)\)/g)].map(m => m[1]) const resolverCalls = [...codeOf(RESOLVER).matchAll(/startReferenceLiveFeed\([\s\S]{0,200}?\)/g)].map(m => m[0]) const all = [...calls.filter(a => !a.includes('domainId: string')), ...resolverCalls] assert.ok(all.length >= 2, `호출처를 찾지 못했다 (${all.length}) — 이 가드가 아무것도 지키지 않는다`) for (const call of all) { assert.match(call, /ref\.source/, `이 호출처가 레퍼런스 이름을 넘기지 않는다: ${call.replace(/\s+/g, ' ')}`) } }) test('이름이 없으면 알리지 않고 돌지 않는다 — 말한다', () => { /* * 커서 없이 도는 것은 「창이 매번 미끄러진다」는 뜻이고, 그 손실은 오류 없이 사라진다. 그래서 그 * 사실을 로그로 말한다 — 실제로 그 한 줄이 이 결함을 찾아냈다. */ const code = codeOf(LIVE) assert.match(code, /if \(!source\)[\s\S]{0,200}twinWarn/, '이름이 없으면 경고한다') assert.match(code, /did not say which reference/, '무엇이 없어서 못 이었는지 말한다') /* 행을 못 찾은 경우도 따로 말한다 — 이름은 왔는데 그 레퍼런스가 없는 것은 다른 사실이다. */ assert.match(code, /no reference row for source/, '레퍼런스 행이 없는 경우를 가려 말한다') }) test('커서는 레퍼런스의 것이다 — 인스턴스마다 두지 않는다', () => { /* * 한 레퍼런스가 여러 인스턴스를 낳고(`scopeSpec.produced`) 원본을 읽는 것은 한 번이다. * 인스턴스마다 커서를 두면 같은 표를 여러 번 읽는다. */ const code = codeOf(LIVE) assert.match(code, /getRepository\(TwinReference\)/, '레퍼런스 행에 저장한다') assert.match(code, /liveCursor: next/, '어댑터가 준 모양을 그대로 적는다') assert.doesNotMatch(code, /liveCursor[\s\S]{0,40}instanceId/, '인스턴스로 커서를 가르지 않는다') }) test('저장 실패를 삼키지 않는다', () => { /* 오류 없이 실패하면 다음 재기동에서 창이 다시 미끄러지고, 이 컬럼을 만든 이유가 사라진다. */ assert.match(codeOf(LIVE), /live cursor not saved/, '저장 실패를 말한다') })