import { Arg, Ctx, Directive, Field, Int, Mutation, ObjectType, Query, Resolver } from 'type-graphql' import { getRepository } from '@things-factory/shell' import { TwinSubjectEvent } from './twin-subject-event.js' import { SUBJECT_KINDS } from './event-subjects.js' import { subjectWriteFailures } from './twin-subject-writer.js' import { backfillSubjects } from './twin-subject-backfill.js' import { hydratedDate } from '../../engine/read-time.js' /* * **대상별 사건 목록 조회** — 360 화면의 이력이 이 문으로 온다. * * ── 무엇이 달라지나 (2026-08-27 실측) ─────────────────────────────────────── * 지금 화면은 저널을 직접 묻고 그 조회가 64초다. 「무엇으로 걸러 시각순으로 정렬해 앞에서 30개」라는 * 모양에 맞는 목차가 저널에 없고, 얹어도 풀리지 않는다(정렬이 목차를 무력화한다 · 품목과 로트가 한 * 문자열에 붙어 있다). * * 이 표는 그 모양대로 생겼다. 목차가 `(도메인, 트윈, 종류, 식별자, 시각)` 이므로 위에서 30줄 읽고 * 끝난다 — 정렬이 없다. * * ── 준비되지 않은 구간을 말한다 ───────────────────────────────────────────── * 이 표는 **앞으로만 채운다**(소급하지 않는다). 그래서 표가 만들어진 시점보다 앞의 사건은 여기에 없다. * 그것을 「사건이 없다」로 그리면 화면이 거짓을 말한다. `coveredFrom` 이 그 경계를 함께 낸다 — * 화면은 「이 목록은 …부터입니다. 그 앞은 기간을 지정해 조회하십시오」를 말할 수 있다. * * 적지 못한 줄이 있으면 그것도 함께 낸다(`writeFailures`). 알리지 않고 비면 화면이 「그 대상에는 사건이 * 없다」고 말하게 된다. * * 설계: `operato-twin/design/plans/read-models.md` */ @ObjectType({ description: 'One row of a subject history list.' }) export class TwinSubjectEventView { @Field({ description: 'Event type.' }) eventType: string @Field({ description: 'When it happened, by the twin clock.' }) eventTime: Date @Field(type => Int, { description: 'Journal revision — where this sits in the record.' }) revision: number @Field({ nullable: true, description: 'Journal row id. Null when that row has been pruned.' }) eventId?: string } @ObjectType({ description: 'History of one subject, newest first, plus how far back this list can speak for. The list is a read model built forward from the journal, so it does not cover events older than coveredFrom — say so on screen instead of drawing an empty list as "nothing happened".' }) export class TwinSubjectHistory { @Field(type => [TwinSubjectEventView], { description: 'The events on this page, newest first.' }) items: TwinSubjectEventView[] @Field({ nullable: true, description: 'Oldest event time this list can speak for, for this twin. Events before it are in the journal but not in this list — the screen must say so rather than implying there were none. Null when the list holds nothing for this twin yet.' }) coveredFrom?: Date @Field({ nullable: true, description: 'Cursor for the next page: pass it back as before. Null when this page is the last.' }) nextBefore?: Date @Field(type => Int, { description: 'Rows this twin failed to write into the read model since start. Greater than zero means this list is incomplete for reasons other than coverage — the journal still holds those events.' }) writeFailures: number } const PAGE_MAX = 200 @Resolver() export class TwinSubjectQuery { @Query(returns => TwinSubjectHistory, { description: 'Events that refer to one subject (item, lot, order, location, equipment or task), newest first. Answered from a read model shaped for this question — one index lookup, no sort — instead of scanning the event journal. Paging is by time (before), not offset, because the list grows at the head. Always reports coveredFrom: this read model is built forward and does not contain events older than that, and an empty list before it means "not covered", not "nothing happened".' }) async twinSubjectHistory( @Ctx() context: any, @Arg('instanceId', { description: 'The twin instance to read.' }) instanceId: string, @Arg('subjectKind', { description: `One of: ${SUBJECT_KINDS.join(', ')}.` }) subjectKind: string, @Arg('subjectId', { description: 'Identifier of the thing, within its kind.' }) subjectId: string, @Arg('limit', type => Int, { nullable: true, description: `How many rows, at most ${PAGE_MAX}.` }) limit?: number, @Arg('before', { nullable: true, description: 'Read events strictly older than this time — the previous page cursor.' }) before?: Date ): Promise { const domain = context.state.domain const kind = String(subjectKind ?? '').trim() /* * 종류는 닫힌 목록이다. 모르는 값이면 빈 목록이 아니라 오류를 낸다 — 빈 목록으로 답하면 오타가 * 「그 대상에는 사건이 없다」로 보인다. */ if (!(SUBJECT_KINDS as readonly string[]).includes(kind)) { throw new Error(`unknown subjectKind "${subjectKind}" — one of: ${SUBJECT_KINDS.join(', ')}`) } const id = String(subjectId ?? '').trim() if (!id) throw new Error('subjectId is required — an empty subject would read the whole twin') const take = Math.min(Math.max(Number(limit) || 30, 1), PAGE_MAX) const repo = getRepository(TwinSubjectEvent) const qb = repo .createQueryBuilder('s') .where('s.domain = :domainId', { domainId: domain.id }) .andWhere('s.instanceId = :instanceId', { instanceId }) .andWhere('s.subjectKind = :kind', { kind }) .andWhere('s.subjectId = :id', { id }) /* * 같은 시각에 사건이 여럿일 수 있다. 그때 순서를 리비전으로 가른다 — 그러지 않으면 같은 목록을 * 두 번 물었을 때 순서가 달라지고, 시각 커서로 넘기는 페이지에서 본 줄이 다시 나온다. */ .orderBy('s.eventTime', 'DESC') .addOrderBy('s.revision', 'DESC') .take(take + 1) /* 시각으로 페이지를 넘긴다 — 머리에 계속 쌓이는 목록에서 건너뛰기(offset)는 본 줄을 다시 낸다. */ if (before) qb.andWhere('s.eventTime < :before', { before }) const rows = await qb.getMany() const items = rows.slice(0, take).map(r => ({ eventType: r.eventType, eventTime: r.eventTime, revision: Number(r.revision), ...(r.eventId ? { eventId: r.eventId } : {}) })) const nextBefore = rows.length > take ? items[items.length - 1]?.eventTime : undefined return { items, ...(await coverage(domain.id, instanceId)), ...(nextBefore ? { nextBefore } : {}), writeFailures: subjectWriteFailures(domain.id, instanceId) } } } /** * 이 표가 이 트윈에 대해 말할 수 있는 가장 오래된 시각 — 저장하지 않고 조회할 때 구한다. * * 저장하면 실제 자료와 어긋날 수 있고, 어긋나면 어느 쪽이 사실인지 알 수 없다. 목차 * `(도메인, 트윈, 시각)` 의 첫 줄 하나라서 값이 싸다. */ async function coverage(domainId: string, instanceId: string): Promise<{ coveredFrom?: Date }> { const first = await getRepository(TwinSubjectEvent) .createQueryBuilder('s') .select('s.eventTime', 'eventTime') .where('s.domain = :domainId', { domainId }) .andWhere('s.instanceId = :instanceId', { instanceId }) .orderBy('s.eventTime', 'ASC') .limit(1) .getRawOne<{ eventTime: unknown }>() .catch(() => null) /* 원시 결과의 컬럼 값은 드라이버에게 해석시킨다(§`hydratedDate`). */ const at = hydratedDate(getRepository(TwinSubjectEvent), 'eventTime', first?.eventTime) return at ? { coveredFrom: at } : {} } @ObjectType({ description: 'How far a backfill got.' }) export class TwinSubjectBackfillResult { @Field({ description: 'The twin that was filled.' }) instanceId: string @Field(type => Int, { description: 'Journal rows read.' }) read: number @Field(type => Int, { description: 'Rows written into the read model.' }) written: number /* * 진행 상황을 리비전으로 말한다 — 옮기는 쪽이 저널을 리비전 순으로 읽고, 그 지점을 이 프로세스가 든다. * * 이 자리를 시각으로 적어 두었다가 옮기는 방식과 어긋났다. 응답의 칸과 실제로 드는 값이 다르면 * 부르는 쪽이 진행을 판단할 수 없다. */ @Field(type => Int, { description: 'Journal revision reached so far.' }) upToRevision: number @Field(type => Int, { description: 'The target: the journal tip at the moment this fill started. Reaching it means the twin is fully filled.' }) targetRevision: number @Field({ description: 'True when upToRevision has reached the target.' }) complete: boolean @Field({ description: 'True when the call stopped on its time budget rather than finishing.' }) paused: boolean } @Resolver() export class TwinSubjectMutation { /* 쓰기는 정본 게이트를 단다 — 로그인만 하면 누구나 부를 수 있는 문을 두지 않는다. */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => TwinSubjectBackfillResult, { description: 'Move already-stored events of one twin into the subject read model. Not automatic: the read model is built forward, so filling old events is an explicit request for a twin that already has a journal. Works in slices on the time axis: each call fills events older than what the read model already covers, so calling it again continues downward with nothing to pass in. Blocks cannot split, and calling it twice does not duplicate rows.' }) async backfillTwinSubjects( @Ctx() context: any, @Arg('instanceId', { description: 'The twin to fill.' }) instanceId: string, @Arg('budgetMs', type => Int, { nullable: true, description: 'How long this call may run, in milliseconds. Default 30000.' }) budgetMs?: number ): Promise { const domain = context.state.domain /* * 이어받기 인자가 없다 — 이 표의 현재 바닥(가장 오래된 시각)이 곧 이어받기 지점이다. 늘 그 아래만 * 채우므로 덩어리가 갈라지지 않고, 부르는 쪽이 아무것도 들고 있지 않아도 이어서 한다. */ return await backfillSubjects(domain.id, instanceId, { budgetMs: budgetMs ?? undefined }) } }