import { decodeCursor, getRepository } from '@things-factory/shell' import { TwinEvent } from '../twin-event/twin-event.js' import { JOURNAL_COUNT_CAP as COUNT_CAP } from '../twin-event/journal-count.js' import { TwinSubjectEvent } from './twin-subject-event.js' import { subjectWriteFailures } from './twin-subject-writer.js' import { itemResolverFor } from './item-resolver.js' import type { SubjectKind } from './event-subjects.js' import { readModelShape, type SubjectQueryShape as Shape } from './subject-query-shape.js' import { absoluteTime } from '../../engine/read-time.js' import { TwinEngine } from '../../engine/index.js' /* * **화면이 이미 부르는 문에서 파생 테이블을 쓴다** (2026-08-28). * * ── 무엇이 잘못되어 있었나 ────────────────────────────────────────────────── * 대상별 사건 목록(`twin_subject_events`)을 만들고 조회도 새로 열었는데(`twinSubjectHistory`), * 360 화면은 예전부터 `twinEventList` 를 부르고 있었다. 그래서 테이블은 8,382,017줄을 채우고도 * 쓰이지 않았고, 화면의 이력 조회는 그대로 64초였다. **자리는 있고 길이 없었다.** * * 새 문으로 옮기라고 화면에 요구하지 않는다. 화면이 이미 서 있는 문 안에서 답을 어디서 가져오는지만 * 바꾼다 — 계약(`TwinEventList`)도, 커서도, 응답 모양도 그대로다. * * ── 어떤 물음일 때만 쓰나 ─────────────────────────────────────────────────── * 파생 테이블은 「대상 하나를 시각순으로」에 맞춰 생겼다. 그 모양이 아닌 물음은 여기서 답하지 않고 * 저널로 보낸다(`null` 을 돌려준다). 좁게 잡는 이유는, 맞지 않는 물음에 억지로 답하면 목록이 * 알리지 않고 달라지기 때문이다. * * 쓴다 트윈 하나 · 축 하나(품목·오더·자리·설비) · 시각 내림차순 · 시각 상한(시간여행)까지 * 안 쓴다 여러 트윈 · 자유 검색 · 종류 필터 · 상관 식별자 · 그 밖의 정렬 * * ── 어디까지 답할 수 있나 ─────────────────────────────────────────────────── * 파생 테이블은 만들어진 뒤부터 채워진다. 그보다 앞의 사건은 저널에는 있고 여기에는 없다. 그 경계를 * `coveredFrom`(이 트윈의 가장 오래된 파생 줄의 시각)이라 하고, **경계를 넘는 페이지는 저널로 보낸다.** * 커서 모양이 저널 경로와 같으므로 넘기는 자리에서 그냥 이어진다 — 앞은 빠르고, 경계 아래는 느리지만 * 온전하다. 없는 것을 「사건이 없다」로 그리는 일은 생기지 않는다. * * 적지 못한 줄이 있으면(`subjectWriteFailures`) 그 창에 구멍이 있다는 뜻이므로 아예 저널로 보낸다. * * 설계: `operato-twin/design/plans/read-models.md` */ export interface FastPathResult { items: TwinEvent[] nextCursor?: string /** 이 트윈에서 파생 테이블이 말할 수 있는 가장 오래된 시각 — 화면이 경계를 말할 수 있게. */ coveredFrom?: Date /** 파생 테이블이 저널의 처음까지 덮고 있나 — 아니면 전체 건수를 「모른다」로 낸다. */ fullyCovered: boolean } /** * 화면이 건넨 축 값 → 파생 테이블의 키. * * 자리·오더·설비는 화면과 저장이 같은 값을 쓴다. **품목만 다르다**: 화면은 짧게 줄인 것을 들고 있고 * (`net/class/product-M1`) 저장된 것은 전체 식별자다. 그 되돌림을 적는 쪽과 **같은 함수**로 한다 * (`itemResolverFor`) — 두 벌로 적으면 어느 날 서로 다른 답을 내고, 그때 이력이 빈다. */ async function canonicalize(domainId: string, instanceId: string, kind: SubjectKind, value: string): Promise { if (kind !== 'item') return value const det = await TwinEngine.detail(domainId, instanceId).catch(() => null) const itemOf = det?.model ? itemResolverFor(domainId, instanceId, det.model) : undefined if (!itemOf) return null return itemOf(value) ?? null } /** * 이 트윈에서 파생 테이블이 말할 수 있는 경계와, 저널의 처음까지 덮는지. * * 둘 다 목차만 읽는 질의다(`(domain, instance, event_time)` 의 첫 줄). 그래서 부를 때마다 구해도 * 값이 싸고, 저장해 두지 않으므로 실제 데이터와 어긋날 일이 없다. */ async function coverage(domainId: string, instanceId: string): Promise<{ from?: Date; full: boolean }> { /* * ── **원시 값을 직접 날짜로 읽지 않는다** (2026-08-28 실측) ────────────────── * * `getRawOne` 은 드라이버가 준 값을 그대로 준다. sqlite 의 그 값은 시간대가 없는 문자열 * (`2026-08-27 14:37:20.049`)이고, 그것을 `new Date()` 로 읽으면 **한국 시간으로 해석**해 * UTC 로 9시간을 뺀다. 실측: 커서의 시각이 9시간 이르게 나가 이어 읽기가 구간을 건너뛰었다. * * `getOne` 은 엔티티로 받으므로 컬럼 변환이 걸린다 — 드라이버가 무엇이든 같은 값이 된다. * 조인이 없으므로 감싸지 않고, 상한이 안쪽에 걸린다(§`twinEvents` 의 실측). */ const [sub, journal] = await Promise.all([ getRepository(TwinSubjectEvent) .createQueryBuilder('s') .where('s.domain = :domainId', { domainId }) .andWhere('s.instanceId = :instanceId', { instanceId }) .orderBy('s.eventTime', 'ASC') .limit(1) .getOne() .catch(() => null), getRepository(TwinEvent) .createQueryBuilder('e') .where('e.domain = :domainId', { domainId }) .andWhere('e.instanceId = :instanceId', { instanceId }) .orderBy('e.eventTime', 'ASC') .limit(1) .getOne() .catch(() => null) ]) const from = sub?.eventTime instanceof Date ? sub.eventTime : undefined const oldest = journal?.eventTime instanceof Date ? journal.eventTime : undefined if (!from || Number.isNaN(from.getTime())) return { from: undefined, full: false } const full = !!oldest && !Number.isNaN(oldest.getTime()) && from.getTime() <= oldest.getTime() return { from, full } } /** * 파생 테이블로 한 페이지 — 답할 수 없으면 `null`(부르는 쪽이 저널로 간다). * * 두 걸음이다. ① 파생 테이블에서 리비전 번호를 페이지만큼 읽는다(목차만 읽으므로 정렬이 없다). * ② 그 번호로 저널 줄을 가져온다(`(domain, instance, revision)` 목차의 키 조회 · 50줄). * 두 번째 걸음이 필요한 이유는 화면이 저널 줄의 내용(`payload`·`bizStep`…)을 그리기 때문이다 — * 파생 테이블은 목차이지 사실의 집이 아니다. */ export async function subjectFastPage( domainId: string, instanceId: string, shape: Shape, limit: number, after?: string ): Promise { /* 적지 못한 줄이 있으면 이 창에 구멍이 있다 — 그때는 온전한 쪽(저널)으로 보낸다. */ if (subjectWriteFailures(domainId, instanceId) > 0) return null const subjectId = await canonicalize(domainId, instanceId, shape.kind, shape.value) if (!subjectId) return null const cov = await coverage(domainId, instanceId) if (!cov.from) return null /* 이어 읽기 지점 — 저널 경로와 **같은 커서**를 읽는다(`[eventTime, revision]`). */ let cursor: { at: Date; revision: number } | null = null if (after) { cursor = decodeJournalCursor(after) if (!cursor) return null } /* * 경계를 넘는 페이지는 여기서 답하지 않는다. 파생 테이블에는 그 아래가 없으므로, 답하면 목록이 * 「여기서 끝」이라고 말하게 된다. 저널로 보내면 커서가 그대로 이어진다. * * 이어 읽기 지점이 경계와 **같을** 때도 보낸다 — 그 커서는 아래에서 우리가 직접 만든 「경계 아래를 * 읽어라」는 표시이고(§`boundaryCursor`), 여기서 받으면 같은 페이지를 되풀게 된다. */ if (cursor && cursor.at.getTime() <= cov.from.getTime()) return null if (shape.untilTime && shape.untilTime.getTime() < cov.from.getTime()) return null const qb = getRepository(TwinSubjectEvent) .createQueryBuilder('s') .select('s.revision', 'revision') .where('s.domain = :domainId', { domainId }) .andWhere('s.instanceId = :instanceId', { instanceId }) .andWhere('s.subjectKind = :kind', { kind: shape.kind }) .andWhere('s.subjectId = :subjectId', { subjectId }) if (shape.fromTime) qb.andWhere('s.eventTime >= :fromTime', { fromTime: shape.fromTime }) if (shape.untilTime) qb.andWhere('s.eventTime <= :untilTime', { untilTime: shape.untilTime }) if (cursor) { /* 시각이 같은 줄이 여럿이므로 리비전으로 갈라 이어 읽는다 — 건너뛰거나 되풀지 않게. */ qb.andWhere('(s.eventTime < :curAt OR (s.eventTime = :curAt AND s.revision < :curRev))', { curAt: cursor.at, curRev: cursor.revision }) } const picked = await qb .orderBy('s.eventTime', 'DESC') .addOrderBy('s.revision', 'DESC') .limit(limit) .getRawMany<{ revision: number }>() .catch(() => null) if (!picked) return null if (!picked.length) { /* 이 대상의 줄이 경계 안에 없다 — 경계 아래는 저널만 안다. 그 사실을 커서로 넘긴다. */ return { items: [], nextCursor: cov.full ? undefined : boundaryCursor(cov.from), coveredFrom: cov.from, fullyCovered: cov.full } } const revisions = picked.map(p => Number(p.revision)).filter(n => Number.isFinite(n)) const rows = await getRepository(TwinEvent) .createQueryBuilder('e') .where('e.domain = :domainId', { domainId }) .andWhere('e.instanceId = :instanceId', { instanceId }) .andWhere('e.revision IN (:...revisions)', { revisions }) .getMany() .catch(() => null) if (!rows) return null /* * 저널 줄을 파생 테이블이 정한 순서로 되돌린다. `IN` 조회는 순서를 약속하지 않으므로 여기서 * 세운다 — 50줄이라 값이 없다. */ const byRevision = new Map(rows.map(r => [Number((r as any).revision), r])) const items = revisions.map(r => byRevision.get(r)).filter(Boolean) as TwinEvent[] /* * 지워진 저널 줄은 목차에 남아 있을 수 있다(보관 정리가 저널을 먼저 지운 창). 그 차이를 세어 * 페이지가 짧아진 것을 알린다 — 그러나 여기서는 커서만 정확히 이어 주면 되고, 잃은 사실은 없다. */ /* * ── 다음 지점 ─────────────────────────────────────────────────────────────── * 페이지가 꽉 찼으면 이어 읽기 지점은 이번 페이지의 마지막 줄이다(저널 경로와 같은 축). * **시각은 저널 줄에서 가져온다** — 목차의 원시 값을 직접 읽으면 시간대가 어긋난다(§`coverage`). * * 짧게 끝났으면 이 대상의 줄을 경계 안에서 다 냈다는 뜻이다. 그때 「없다」로 끝내면 안 된다 — * 경계 아래에 저널만 아는 사건이 있을 수 있고, 그것을 끝으로 그리면 목록이 알리지 않고 잘린다. * 그래서 **경계 아래를 읽으라는 커서**를 낸다. 저널이 그 다음 페이지를 답한다(느리지만 온전하다). * * 파생 테이블이 저널의 처음까지 덮고 있으면 아래에 아무것도 없으므로 그때만 끝이다. */ let edge: { at: Date; revision: number } | null = null if (picked.length === limit) { /* 지워진 저널 줄은 목차에 남아 있을 수 있다 — 줄이 있는 마지막 것에서 지점을 만든다. */ for (let i = revisions.length - 1; i >= 0; i--) { const row: any = byRevision.get(revisions[i]) if (row?.eventTime instanceof Date && !Number.isNaN(row.eventTime.getTime())) { edge = { at: row.eventTime, revision: revisions[i] } break } } } const nextCursor = edge ? encodeJournalCursor(edge.at, edge.revision) : cov.full ? undefined : boundaryCursor(cov.from) return { items, nextCursor, coveredFrom: cov.from, fullyCovered: cov.full } } /** * 「경계보다 오래된 것을 읽어라」는 지점. * * 저널 경로의 커서 조건은 `(시각 < v) 또는 (시각 = v 그리고 리비전 < v2)` 다. 그래서 리비전 자리에 * 0 을 두면 뒷절이 성립하지 않고 **시각이 경계보다 오래된 것만** 남는다 — 경계에 있는 줄은 이미 * 냈으므로 그것이 정확히 남은 구간이다. 이 커서를 다시 받으면 이 경로는 물러난다(위의 경계 검사). */ function boundaryCursor(from: Date): string { return encodeJournalCursor(from, 0) } /** * 목록 계약 한 페이지를 파생 테이블로 답한다 — 답할 수 없으면 `null`(부르는 쪽이 저널로 간다). * * ── 전체 건수와 「더 있나」 ────────────────────────────────────────────────── * 두 칸을 함께 정직하게 채워야 한다. 화면은 전체 건수를 모를 때 `capped` 로 「더 있나」를 판단한다 * (`hasMore`). 그래서 건수를 모르면서 `capped` 를 내리면 **목록이 알리지 않고 끝난 것처럼 보인다.** * * 파생 테이블이 저널의 처음까지 덮는다 → 정확한 전체 건수 · capped 내림 * 덮지 못한다(앞이 저널에만 있다) → 전체 건수 모름 · capped 올림(가진 수는 하한이다) */ export async function readModelPage(domainId: string, instanceId: string, params: any, limit: number): Promise { const shape = readModelShape(params, params?.sortings ?? []) if (!shape) return null const page = await subjectFastPage(domainId, instanceId, shape, limit, params?.pagination?.after ?? undefined) if (!page) return null let total: number | undefined if (page.fullyCovered) { const counted = await countSubjectRows(domainId, instanceId, shape) if (counted !== null && counted <= COUNT_CAP) total = counted } /* 정확한 수를 낼 수 없으면 「모른다」로 내고, 가진 수가 하한임을 함께 말한다. */ const capped = total === undefined return { items: page.items, total, capped, countCap: COUNT_CAP, nextCursor: page.nextCursor, oldestRevision: await oldestJournalRevision(domainId, instanceId) } } /** 목록 계약이 돌려주는 모양 — 저널 경로와 같은 칸을 채운다. */ export interface TwinEventListShape { items: TwinEvent[] total?: number capped: boolean countCap: number nextCursor?: string oldestRevision?: number } /** 이 대상의 사건 수 — 목차만 읽는다(`(도메인, 트윈, 종류, 식별자, 시각)` 의 앞부분). */ async function countSubjectRows(domainId: string, instanceId: string, shape: Shape): Promise { const subjectId = await canonicalize(domainId, instanceId, shape.kind, shape.value) if (!subjectId) return null const qb = getRepository(TwinSubjectEvent) .createQueryBuilder('s') .where('s.domain = :domainId', { domainId }) .andWhere('s.instanceId = :instanceId', { instanceId }) .andWhere('s.subjectKind = :kind', { kind: shape.kind }) .andWhere('s.subjectId = :subjectId', { subjectId }) if (shape.fromTime) qb.andWhere('s.eventTime >= :fromTime', { fromTime: shape.fromTime }) if (shape.untilTime) qb.andWhere('s.eventTime <= :untilTime', { untilTime: shape.untilTime }) return qb.getCount().catch(() => null) } /** * 이 트윈에 남아 있는 가장 오래된 리비전 — 보관 정리로 앞이 지워졌는지 화면이 말할 수 있게. * 저널 경로가 내는 것과 같은 값이고 같은 이유다(없었던 것과 지워진 것은 다른 사실이다). */ async function oldestJournalRevision(domainId: string, instanceId: string): Promise { const first = await getRepository(TwinEvent) .createQueryBuilder('e') .select('e.revision', 'revision') .where('e.domain = :domainId', { domainId }) .andWhere('e.instanceId = :instanceId', { instanceId }) .orderBy('e.revision', 'ASC') .limit(1) .getRawOne<{ revision: unknown }>() .catch(() => null) const at = Number(first?.revision) return Number.isFinite(at) && at > 0 ? at : undefined } /** * 저널 경로와 **같은 축의** 커서를 만든다 — `[eventTime, revision]`. * * 시각이 같은 줄들 사이에서 무엇이 앞인지 가리는 축이 두 경로에서 같아야 한다. 다르면 한 목록을 * 나눠 답하는 자리에서 본 줄이 또 나온다(실측: 120건 중 15건). 저널 경로도 트윈 하나를 볼 때는 * `revision` 으로 가린다(§`cursorTiebreak`). */ function encodeJournalCursor(at: Date, revision: number): string { return Buffer.from(JSON.stringify([at, revision]), 'utf8').toString('base64url') } /** * 커서를 파생 테이블이 쓸 수 있는 지점으로 읽는다 — `[eventTime, revision]`. * * 예전에는 커서에 저널 줄의 `id` 가 실려 있어서 그 줄을 한 번 읽어 리비전을 얻었다. 그 방식은 * **지워진 줄에서 어긋났다**: 보관 정리가 저널을 지우면 리비전을 얻지 못하고, 얻은 리비전이 * 이어 읽기 지점의 시각과 다른 줄의 것일 수 있었다. 이제 두 경로가 같은 축을 쓰므로 커서에 실린 * 값을 그대로 읽는다 — 조회도 필요 없다. */ function decodeJournalCursor(cursor: string): { at: Date; revision: number } | null { let values: unknown[] try { values = decodeCursor(cursor) } catch { return null } if (values.length !== 2) return null const at = absoluteTime(values[0]) const revision = Number(values[1]) if (!at || !Number.isFinite(revision)) return null return { at, revision } }