/* * ★ 조회용 파생 표의 규율 — 앞으로만 채우고, 담지 않은 구간을 말한다. * * ── 왜 이 표를 만들었나 (2026-08-27 실측) ─────────────────────────────────── * 360 화면의 「이 대상의 이력」이 64초다. 「무엇으로 걸러 시각순으로 정렬해 앞에서 30개」라는 모양에 * 맞는 목차가 저널에 없고, 얹어도 풀리지 않는다. * * 여러 값으로 거르면(IN) 정렬 때문에 목차를 못 쓴다 — 실측한 질의 계획 * 축이 늘 때마다 저널의 목차가 늘어난다 — 360 의 축 넷이면 13개 * 품목과 로트가 한 문자열에 붙어 있어 품목만으로는 찾을 수 없다 — 정확일치가 0건 * * ── 이 테스트가 무엇을 지키나 ─────────────────────────────────────────────── * DB 를 띄우지 않는다. 지켜야 할 것은 **규율**이고 그것은 순수하게 확인할 수 있다 — 목차가 조회 모양과 * 같은 순서인가, 소급 채우기를 하지 않는가, 담지 않은 구간을 말하는가, 저널이 먼저인가. * * 설계: `operato-twin/design/plans/read-models.md` */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { join } from 'node:path' const dir = (...p: string[]) => join(import.meta.dirname, '..', 'server', ...p) const entity = readFileSync(dir('service', 'twin-subject', 'twin-subject-event.ts'), 'utf8') const writer = readFileSync(dir('service', 'twin-subject', 'twin-subject-writer.ts'), 'utf8') const query = readFileSync(dir('service', 'twin-subject', 'twin-subject-query.ts'), 'utf8') const resolver = readFileSync(dir('service', 'twin-subject', 'item-resolver.ts'), 'utf8') const engine = readFileSync(dir('engine', 'twin-engine.ts'), 'utf8') test('★ 목차 순서가 조회 모양과 같다 — 이 순서가 이 표의 존재 이유다', () => { /* * 「이 트윈의 이 대상의 것을 최신순으로 30개」가 빠르려면 목차가 거르는 값 다음에 시각이어야 한다. * 순서가 바뀌면 다시 정렬하게 되고, 그것이 저널에서 64초가 된 바로 그 원인이다. */ assert.match( entity, /ix_twin_subject_event_0'[\s\S]{0,160}?\[e\.domain, e\.instanceId, e\.subjectKind, e\.subjectId, e\.eventTime\]/, '(도메인, 트윈, 종류, 식별자, 시각) 순서여야 한다' ) /* 보관 상한을 지우는 축도 시각이 마지막이어야 한다(경계를 시각으로 찾으므로). */ assert.match( entity, /ix_twin_subject_event_1'[\s\S]{0,160}?\[e\.domain, e\.instanceId, e\.eventTime\]/, '(도메인, 트윈, 시각) 목차가 있어야 한다' ) }) test('★ 시각 컬럼에 드라이버 방언을 적지 않는다 — datetime 은 Postgres 가 거절한다', () => { const at = entity.indexOf('eventTime: Date') assert.ok(at > 0, 'eventTime 이 있어야 한다') const decl = entity.slice(Math.max(0, at - 400), at) assert.doesNotMatch(decl, /type: 'datetime'/, 'sqlite·MySQL 의 이름을 적지 않는다') assert.doesNotMatch(decl, /type: 'timestamp'/, 'Postgres 의 이름도 적지 않는다') }) test('★ 저널이 먼저다 — 이 표에 적다가 실패해도 저널 쓰기를 되돌리지 않는다', () => { /* 사실의 원본은 저널이고 이 표는 다시 만들 수 있다. 반대로 두면 조회를 빠르게 하려고 만든 표가 사실을 잃는 원인이 된다. */ assert.match(writer, /catch \(err: any\)[\s\S]{0,200}?noteFailure/, '실패를 잡아 세어야 한다') assert.match(writer, /return 0\s*\n\s*\}/, '실패해도 던지지 않는다') }) test('★ 알리지 않고 비지 않는다 — 적지 못한 줄 수를 조회가 함께 낸다', () => { /* 알리지 않고 비면 화면이 「그 대상에는 사건이 없다」고 말하게 된다. 그것이 이 부류에서 가장 나쁜 결함이다. */ assert.match(writer, /export function subjectWriteFailures/, '적지 못한 수를 물을 수 있어야 한다') assert.match(query, /writeFailures: subjectWriteFailures\(/, '조회가 그 수를 실어 보낸다') assert.match(query, /writeFailures: number/, '응답에 그 칸이 있어야 한다') }) test('★ 담지 않은 구간을 말한다 — 앞으로만 채우므로 그 앞은 「없다」가 아니다', () => { assert.match(query, /coveredFrom\?: Date/, '경계를 낼 칸이 있어야 한다') assert.match(query, /orderBy\('s\.eventTime', 'ASC'\)[\s\S]{0,120}?limit\(1\)/, '가장 오래된 줄 하나로 구한다') /* 저장하지 않고 조회할 때 구한다 — 저장한 값은 실제 자료와 어긋날 수 있다. */ assert.doesNotMatch(entity, /coveredFrom/, '경계를 표에 저장하지 않는다') }) test('★ 종류가 닫힌 목록이고, 모르는 값은 빈 목록이 아니라 오류다', () => { /* 빈 목록으로 답하면 오타가 「그 대상에는 사건이 없다」로 보인다. */ assert.match(query, /SUBJECT_KINDS as readonly string\[\]\)\.includes\(kind\)/, '닫힌 목록으로 검사한다') assert.match(query, /throw new Error\(`unknown subjectKind/, '모르는 종류는 오류를 낸다') assert.match(query, /subjectId is required/, '빈 식별자로 트윈 전체를 읽지 않는다') }) test('★ 페이지를 시각으로 넘긴다 — 머리에 쌓이는 목록에서 건너뛰기는 본 줄을 다시 낸다', () => { assert.match(query, /eventTime < :before/, '시각 커서로 넘긴다') assert.doesNotMatch(query, /\.skip\(/, '건너뛰기(offset)를 쓰지 않는다') }) test('★ 품목은 선언과 견주어 판정한다 — 문자열을 자르지 않는다', () => { /* * 어느 부호가 로트를 가리키는지는 연결된 시스템마다 다르고 커널 계약이 정한 적이 없다. 한 시스템의 * 관습(`-lot-`)을 규칙으로 굳히면 이 코드가 그 시스템 위에 서게 된다. */ assert.match(resolver, /resolveByDeclaration\(itemRefOf\(epc\)/, '선언과 견주어 찾는다') /* 주석은 그 부호를 설명으로 쓴다 — 코드에 있는지만 본다(주석 줄을 걷어 내고 검사한다). */ const code = resolver .split('\n') .filter(l => !/^\s*(\*|\/\*|\/\/)/.test(l)) .join('\n') assert.doesNotMatch(code, /'-lot-'|"-lot-"|split\('-'\)|indexOf\('-lot/, '문자열을 자르는 규칙을 넣지 않는다') /* 사건마다 부르므로 기억해 둔다. 선언이 바뀌면 세대가 달라져 다시 판정한다. */ assert.match(resolver, /generation !== generation|entry\.generation !== generation/, '선언 세대를 본다') }) test('★ 저널을 쓰는 두 문이 모두 이 표에 적는다 — 한쪽만 적으면 그만큼이 화면에서 빈다', () => { const carried = engine.indexOf('static async persistCarried') const batch = engine.indexOf('static async persistBatch') assert.ok(carried > 0 && batch > 0, '두 문이 있어야 한다') assert.match(engine.slice(carried, carried + 900), /this\.writeSubjects\(/, 'persistCarried 가 적는다') assert.match(engine.slice(batch, batch + 900), /this\.writeSubjects\(/, 'persistBatch 가 적는다') }) test('★ 소급해서 채우지 않는다 — 마이그레이션이 표만 만든다', () => { const migration = readFileSync(dir('migrations', '1786500000000-CreateTwinSubjectEvents.ts'), 'utf8') assert.match(migration, /createTable/, '표를 만든다') assert.match(migration, /createIndex/, '목차를 만든다') /* 채우는 코드가 있으면 새 조회 축마다 3,128만 줄을 다시 만드는 구조가 된다. */ assert.doesNotMatch(migration, /INSERT|insert\(|from twin_events|TwinEvent/i, '기존 사건을 옮기지 않는다') const list = readFileSync(dir('migrations', 'index.ts'), 'utf8') assert.match(list, /CreateTwinSubjectEvents1786500000000/, '마이그레이션 목록에 배선돼 있어야 한다') }) test('★ 이 표의 보관 정리는 체크포인트 상한을 보지 않는다 — 잃을 사실이 없다', () => { /* * 저널의 정리는 체크포인트가 반영하지 못한 사건을 지우지 않는다(그것을 지우면 사실이 사라진다). * 이 표는 사건에서 파생된 값이라 지워도 사실이 사라지지 않는다 — 그 구간의 조회가 느려질 뿐이다. */ assert.match(writer, /export async function pruneSubjectRows/, '정리 함수가 있어야 한다') assert.doesNotMatch(writer, /revision <= :upTo/, '체크포인트 상한을 보지 않는다') assert.match(writer, /orderBy\('s\.eventTime', 'ASC'\)/, '경계를 시각으로 찾는다 — 목차를 쓰려면 그래야 한다') /* 저널 정리와 같은 창에서 돈다 — 두 표가 같은 보관 기간을 본다. */ assert.match(engine, /pruneSubjectRows\(/, '저널 정리 창에서 함께 부른다') }) test('★ 어디까지 옮겼는지 이 프로세스가 든다 — 파생 테이블의 내용으로 알아내지 않는다', () => { /* * 저장하지 않고 파생 테이블의 내용으로 진행 지점을 알아내려 했고 세 번 틀렸다. * * ① 최대 리비전 뒤부터 실시간으로 채워진 줄의 최댓값이 곧 저널의 끝이라 옮길 구간을 다 건너뛴다 * ② 최소 리비전 앞까지 1부터 적으면 최솟값이 1이 되어 구간이 0으로 접힌다 * ③ 최소 시각 아래로 사건이 시각순으로 오지 않는 트윈에서 곧바로 끝난다 * * 채워진 것은 구간이 아니라 집합이다. 어느 축으로도 하나의 경계로 말할 수 없다. * * 그래서 알아내려 하지 않고 든다. **테이블에 적지 않는다**(사용자 판단) — 사건이 들어올 때 파생 * 테이블도 같이 적히므로, 옛 사건을 옮기는 일은 새 파생 테이블을 만들 때와 계산 규칙을 고쳤을 때뿐이다. * 대가는 서버가 내려가면 그 몫을 다시 읽는 것이다. */ const backfill = readFileSync(dir('service', 'twin-subject', 'twin-subject-backfill.ts'), 'utf8') assert.match(backfill, /const inFlight = new Map { assert.match(query, /addOrderBy\('s\.revision', 'DESC'\)/, '같은 시각은 리비전으로 가른다') }) test('★ 선언한 엔티티를 등록한다 — 선언만 하면 테이블이 만들어지지 않는다', () => { /* * 파생 테이블 엔티티를 만들고 `entities` 에 넣지 않은 적이 있다. 그러면 테이블이 만들어지지 않고, * 그것을 쓰는 코드가 「그런 테이블 없다」로 실패한다. 파일이 있는 것과 등록된 것은 다르다. */ const index = readFileSync(dir('service', 'twin-subject', 'index.ts'), 'utf8') assert.match(index, /entities = \[TwinSubjectEvent\]/, '이 폴더의 엔티티가 등록돼 있어야 한다') const services = readFileSync(dir('service', 'index.ts'), 'utf8') assert.match(services, /\.\.\.TwinSubjectEntities/, '서비스 목록에도 실려야 한다') /* 마이그레이션의 테이블 이름은 TypeORM 이 만든 것과 글자 그대로 같아야 한다. */ const migration = readFileSync(dir('migrations', '1786500000000-CreateTwinSubjectEvents.ts'), 'utf8') assert.match(migration, /const TABLE = 'twin_subject_events'/, '실제 테이블 이름을 쓴다') })