import { getRepository } from '@things-factory/shell' import { TwinEvent } from '../twin-event/twin-event.js' import { absoluteTime } from '../../engine/read-time.js' import { MAX_SPLIT, type SplitShape } from './event-type-split-shape.js' /* * **여러 종류를 시각순으로 — 종류를 하나씩 나눠 묻는다** (2026-08-28 실측). * * ── 무엇이 느렸나 ─────────────────────────────────────────────────────────── * 에너지 원장은 「이 여덟 종류를 시각 내림차순으로 50건」을 묻는다. 그 모양에서 sqlite 는 종류 색인을 * **버린다**: * * 종류 IN + 시각순 SEARCH USING INDEX ix_twin_event_1 (domain, instance) 37.181초 · 결과 0건 * 같은 조건 · 정렬 없음 SEARCH USING INDEX ix_twin_event_7 (…, event_type=?) 0.003초 * 종류 하나 + 시각순 SEARCH USING INDEX ix_twin_event_7 (…, event_type=?) 0.003초 * * 정렬을 붙이는 순간 여러 종류의 순서를 합칠 방법이 없어, 시각 색인으로 337만 행을 걸으며 행마다 * 종류를 확인한다. 승화푸드에 에너지 사건은 0건이다 — **없는 것을 찾느라 37초**를 쓴다. * * 360 이력이 64초였던 것과 같은 부류다: **거르는 축과 정렬 축이 어긋난다.** * * ── 고침 ──────────────────────────────────────────────────────────────────── * 종류를 **정확일치로 하나씩** 물으면 색인이 시각 순서까지 그대로 준다(정렬이 사라진다). 여덟 종류면 * 질의 여덟 개이고 각 0.003초다. 받은 것을 시각순으로 합쳐 한 페이지를 만든다. * * 새 색인도, 새 테이블도 필요 없다 — 있는 색인을 쓰게 하는 것이 전부다. * * ── 좁게 잡는다 ───────────────────────────────────────────────────────────── * 이 모양이 아닌 물음은 답하지 않고(`null`) 저널의 일반 경로로 보낸다. 억지로 답하면 목록이 알리지 * 않고 달라진다. * * ── ★ **이 갈래를 걷을 조건** ──────────────────────────────────────────────── * 앱에서 종류를 여러 값으로 보내는 곳은 **에너지 원장 한 곳뿐**이다(2026-08-28 확인). 그 화면이 * 종류를 하나씩 고르는 모양으로 바뀌면 이 갈래를 부르는 데가 없어진다 — 그때 **지운다.** * * 부르는 데가 없는 갈래를 들고 있으면, 그것이 무언가를 받쳐 주는 것처럼 보이면서 실제로는 아무것도 * 하지 않는다. 지금 살려 두는 이유는 하나다: 실 고객 트윈에서 37초 질의가 돌고 있고 화면 수정은 * 다른 레인의 일이다. */ export interface SplitPage { items: TwinEvent[] nextCursor?: string } /** * 나눠 묻고 합쳐 한 페이지 — 답할 수 없으면 `null`. * * 이어 읽기 지점은 **모든 갈래에 같이** 건다. 그래야 합친 결과가 커서 아래에서만 나온다 — 갈래마다 * 다르게 걸면 어떤 종류는 이미 지나간 구간을 다시 낸다. */ export async function eventTypeSplitPage( domainId: string, instanceIds: string[], shape: SplitShape, limit: number, /** 동시각을 가리는 축 — 트윈 하나면 `revision`, 여럿이면 기본키(§`cursorTiebreak`). */ tiebreak: 'revision' | 'id', after?: string ): Promise { const pairs = instanceIds.length * shape.eventTypes.length if (!pairs || pairs > MAX_SPLIT) return null let cursor: { at: Date; tie: string | number } | null = null if (after) { cursor = decodeCursorPair(after) if (!cursor) return null } const rows: TwinEvent[][] = [] for (const instanceId of instanceIds) { for (const eventType of shape.eventTypes) { const qb = getRepository(TwinEvent) .createQueryBuilder('e') .where('e.domain = :domainId', { domainId }) .andWhere('e.instanceId = :instanceId', { instanceId }) /* **정확일치**가 요점이다 — 그래야 색인이 시각 순서까지 준다(§ 위 실측). */ .andWhere('e.eventType = :eventType', { eventType }) /* 단계는 색인이 아니라 행 조건이다 — 종류 정확일치가 이미 걷는 범위를 좁혀 놓았다. */ if (shape.bizStep) qb.andWhere('e.bizStep = :bizStep', { bizStep: shape.bizStep }) if (shape.fromTime) qb.andWhere('e.eventTime >= :fromTime', { fromTime: shape.fromTime }) if (shape.untilTime) qb.andWhere('e.eventTime <= :untilTime', { untilTime: shape.untilTime }) if (cursor) { qb.andWhere(`(e.eventTime < :curAt OR (e.eventTime = :curAt AND e.${tiebreak} < :curTie))`, { curAt: cursor.at, curTie: cursor.tie }) } const page = await qb .orderBy('e.eventTime', 'DESC') .addOrderBy(`e.${tiebreak}`, 'DESC') .limit(limit) .getMany() .catch(() => null) /* 한 갈래라도 못 읽으면 합친 결과가 반쪽이다 — 반쪽을 전부인 것처럼 내지 않는다. */ if (!page) return null rows.push(page) } } /* * 합친다. 갈래마다 `limit` 까지 받았으므로, 합쳐서 앞의 `limit` 개는 **전체에서도** 앞의 `limit` 개다 * (각 갈래가 자기 안에서 정렬돼 있고 그보다 앞선 것을 빠뜨릴 수 없다). */ const merged = rows .flat() .sort((a: any, b: any) => { /* `getMany` 는 엔티티를 주므로 시각이 이미 날짜다 — 감싸면 드라이버 값을 손으로 읽는 모양이 된다. */ const at = (b.eventTime as Date).getTime() - (a.eventTime as Date).getTime() if (at !== 0) return at const x = a[tiebreak] const y = b[tiebreak] return x === y ? 0 : x < y ? 1 : -1 }) .slice(0, limit) const last: any = merged[merged.length - 1] const nextCursor = merged.length === limit && last?.eventTime instanceof Date && last[tiebreak] !== undefined ? encodeCursorPair(last.eventTime, last[tiebreak]) : undefined return { items: merged, nextCursor } } /** 저널 경로와 같은 커서 — `[eventTime, 동시각 축]`(§`buildNextCursor`). */ function encodeCursorPair(at: Date, tie: string | number): string { return Buffer.from(JSON.stringify([at, tie]), 'utf8').toString('base64url') } function decodeCursorPair(cursor: string): { at: Date; tie: string | number } | null { let values: unknown try { values = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) } catch { return null } if (!Array.isArray(values) || values.length !== 2) return null const at = absoluteTime(values[0]) const tie = values[1] if (!at || (typeof tie !== 'string' && typeof tie !== 'number')) return null return { at, tie } }