/* * **이 물음이 「여러 종류를 시각순으로」인가** — 순수 판단만 여기 둔다. * * 데이터베이스를 부르지 않는다. 그래서 시험이 이 파일을 그대로 부를 수 있다(데코레이터가 없다). * 판단이 조회 코드 안에 섞여 있으면 그 판단만 확인할 방법이 없고, 그러면 「어떤 물음이 나눠 묻는 * 갈래로 가는가」가 아무도 읽지 못하는 규칙이 된다. * * 실측 근거와 고침의 뜻은 `event-type-split.ts` 머리에 있다. */ import { absoluteTime } from '../../engine/read-time.js' /** 질의를 몇 개까지 나눌까 — 대상 × 종류. 이 이상이면 나누는 것이 손해다. */ export const MAX_SPLIT = 24 /** 종류 목록의 상한 — 이보다 많으면 「이 종류만」이 아니라 사실상 전체다. */ const MAX_TYPES = 12 export interface SplitShape { eventTypes: string[] /** * 공정 단계 하나로 좁힌 경우 — 그 값을 갈래마다 함께 건다. * * 이 필터를 만나면 갈래를 포기하던 것을 고쳤다(2026-08-29). 원장은 종류를 담아 묻고 그 위에 단계 * 칩을 겹치는데, 칩을 고른 순간 예전 경로로 떨어져 **고르지 않은 쪽이 더 빨랐다.** * * 단계 컬럼에는 색인이 없다. 그래도 느려지지 않는다 — 종류 정확일치가 색인으로 시각 순서까지 주고, * 단계는 그 안에서 행마다 확인하는 조건이다(걷는 행이 그 종류의 것으로 이미 좁혀져 있다). */ bizStep?: string untilTime?: Date fromTime?: Date } /** * 이 물음이 「여러 종류를 시각순으로」인가 — 아니면 `null`. * * 받는 모양 * `eventType` 을 `in` 으로 · 시각 상한이나 구간 · 시각 내림차순 정렬 * * 보내는 모양(일반 경로로) * 자유 검색어 · 그 밖의 필터(축·상관 식별자·업무 단계) · 그 밖의 정렬이나 연산자 */ export function splitShape(params: any, sortings: any[]): SplitShape | null { const filters: any[] = Array.isArray(params?.filters) ? params.filters : [] if (typeof params?.search === 'string' && params.search.trim()) return null if (sortings.length !== 1 || sortings[0]?.name !== 'eventTime' || sortings[0]?.desc !== true) return null let eventTypes: string[] | undefined let untilTime: Date | undefined let fromTime: Date | undefined let bizStep: string | undefined for (const f of filters) { if (f?.name === 'eventType') { if (f.operator !== 'in' || !Array.isArray(f.value)) return null const types: string[] = f.value.map((v: unknown) => String(v ?? '').trim()).filter((t: string) => !!t) /* 하나면 나눌 것이 없다(이미 정확일치라 색인을 탄다). 너무 많으면 사실상 전체다. */ if (types.length < 2 || types.length > MAX_TYPES) return null if (eventTypes) return null eventTypes = [...new Set(types)] } else if (f?.name === 'bizStep') { /* 하나로 좁힌 경우만 받는다 — 여러 값이면 종류 × 단계로 갈래가 곱해진다. */ if (f.operator !== 'eq') return null const v = String(f.value ?? '').trim() if (!v) return null if (bizStep) return null bizStep = v } else if (f?.name === 'eventTime') { if (f.operator === 'lte' || f.operator === 'lt') { untilTime = absoluteTime(f.value) ?? undefined if (!untilTime) return null } else if (f.operator === 'between' && Array.isArray(f.value)) { fromTime = absoluteTime(f.value[0]) ?? undefined untilTime = absoluteTime(f.value[1]) ?? undefined if (!fromTime || !untilTime) return null } else return null } else return null } return eventTypes ? { eventTypes, untilTime, fromTime, bizStep } : null } /** * **나눠 물은 갈래들을 합쳐 앞에서 `limit` 개** — 이 규율이 정확성의 근거다. * * ── 왜 갈래마다 `limit` 까지 받아야 하나 ──────────────────────────────────── * 갈래마다 `limit` 개를 받아 합치면, 합친 것의 앞 `limit` 개는 **전체에서도** 앞 `limit` 개다. * 어떤 갈래에서 빠진 행은 그 갈래의 `limit` 번째보다 뒤이고, 우리는 전체에서 `limit` 개만 취하므로 * 그 행이 이 페이지에 들어올 수 없다. * * 갈래마다 **더 적게** 받으면 그 성질이 깨진다. 한 갈래가 앞을 다 차지하는 경우 * (종류 A 의 마지막이 종류 B 의 첫째보다 앞선다) 그 갈래에서 못 받은 것이 빠지고, 결과는 구간의 * 앞부분만 계산한 것이 된다 — 그런데 화면은 「일부만 읽었다」고만 말하고 어느 부분인지는 말하지 못한다. * (2026-08-28 · UX 레인이 미리 짚어 준 함정이다.) * * 그래서 이 함수는 **`limit` 미만으로 받은 갈래가 있으면 그것을 말한다** — 부르는 쪽이 그 상태로 * 답하지 않게. */ export function mergeStreams( streams: readonly T[][], limit: number, /** 앞에 와야 하는 것이 음수 — `Array.prototype.sort` 와 같은 규약. */ compare: (a: T, b: T) => number ): { items: T[]; capped: boolean; underfilled: boolean } { const underfilled = streams.some(s => s.length > limit) const merged = streams.flat().sort(compare) return { items: merged.slice(0, limit), /* 합친 것이 상한을 넘었으면 더 있었다는 뜻이다 — 잘랐다는 사실을 부르는 쪽이 말해야 한다. */ capped: merged.length > limit || streams.some(s => s.length >= limit), /* 갈래가 상한보다 많이 받아 왔다 — 있을 수 없는 일이므로 부르는 쪽이 알아야 한다. */ underfilled } }