import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn } from 'typeorm' import { ObjectType, Field, ID, Int, Float } from 'type-graphql' import { Domain } from '@things-factory/shell' /* * 인제스트 창 하나 — **동기화 추이를 재기동 뒤에도 이어 보기 위한 행.** * * ── 왜 창마다이고 건마다가 아닌가 ─────────────────────────────────────────── * 거부마다 행을 쓰면 라이브 계측 규모에서 초당 1만 행이고, 그 부하가 이벤트 루프로 돌아온다. 창마다면 * 트윈 1,000개에 하루 14.4만 행(약 14MB)이다. 그래서 **닫힌 창 하나가 영속의 단위**다. * * 건마다 남기는 자리는 이미 따로 있다: 시나리오 경로가 `PayloadLog` 로 dead-letter 를 남긴다(배치성 * 유입이라 건수가 감당된다). 이 표는 그것을 대신하지 않고 **추이**를 답한다. * * ── 왜 사유·표본은 여기 없나 ──────────────────────────────────────────────── * 사유 문구는 길고 표본에는 **고객의 레코드 원문**이 들어 있다. 그것을 창마다 영속하면 표가 커지고 * 고객 데이터가 오래 남는다. 사유와 표본은 메모리 장부가 답하고(최근 6시간), 이 표는 숫자만 든다. * * ── 고치지 않는다 ─────────────────────────────────────────────────────────── * 닫힌 창은 지나간 사실이다. 그래서 `updatedAt`·`deletedAt` 을 두지 않는다(저널과 같은 규율). */ @Entity() @Index('ix_twin_ingest_window_0', (e: TwinIngestWindow) => [e.domain, e.instanceId, e.closedAt], { unique: false }) @ObjectType({ description: 'One closed ingest window for a twin: how many records were offered, accepted and rejected between two times. Written when a window closes (about every 10 minutes per twin) so the sync trend survives a restart and reaches further back than the in-memory ledger. Reasons and rejected-record samples are deliberately not stored here — they live in the in-memory ledger, which covers roughly the last 6 hours.' }) export class TwinIngestWindow { @PrimaryGeneratedColumn('uuid') @Field(type => ID, { description: 'Unique identifier of the window record.' }) readonly id: string @ManyToOne(type => Domain) @Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' }) domain?: Domain @RelationId((e: TwinIngestWindow) => e.domain) domainId?: string @Column() @Field({ description: 'Twin runtime instance this window belongs to.' }) instanceId: string @Column() @Field({ description: 'When the window opened.' }) openedAt: Date @Column() @Field({ description: 'When the window closed. Windows close on the next ingest after the window length elapses, so a feed that stops leaves its last window open and unwritten.' }) closedAt: Date @Column({ type: 'int' }) @Field(type => Int, { description: 'Records offered in this window, whether or not they were accepted.' }) offered: number @Column({ type: 'int' }) @Field(type => Int, { description: 'Records the kernel refused in this window because they did not map — the fix is in the mapping.' }) rejected: number /* * 거부와 따로 센다 — **처방이 다르다.** * * 트윈이 라이브로 돌지 않으면 커널이 레코드를 버린다. 그것을 거부에 합치면 「매핑을 고쳐라」로 읽히고, * 통과에 합치면 멈춘 트윈이 100% 로 보인다(실제로 그렇게 떴다). 그래서 제3의 수로 남긴다. */ @Column({ type: 'int', default: 0 }) @Field(type => Int, { description: 'Records that mapped fine but never reached the twin, because the twin was not running live while the feed kept delivering. Distinct from rejected: the fix here is to start the twin or disconnect the feed, not to change the mapping. Offered minus rejected minus undelivered is what actually reached the twin.' }) undelivered: number /* * 통과율을 **함께 적는다.** 읽는 쪽에서 나누게 하면 소비처마다 반올림이 갈리고(0.999 를 1 로 쓰는 * 곳이 생긴다), 제시가 0 인 창에서 0으로 나누는 실수가 각자 생긴다. 제시가 0 이면 **비어 둔다** — * 1 로 채우면 「전부 통과」로 읽힌다. */ @Column({ type: 'float', nullable: true }) @Field(type => Float, { nullable: true, description: 'Share of offered records that reached the twin (0–1), rounded to four places. Null when nothing was offered in this window — an empty window is not a perfect one, and writing 1 there would read as "everything passed".' }) acceptedRatio?: number /* * ── 적힌 것도 같은 창에 든다 ──────────────────────────────────────────────── * 유입과 **다른 축**이다. 시뮬 트윈은 받는 것 없이 자기가 낸 사실을 적으므로 `offered` 가 0 이고 * `journaledRows` 는 0 이 아니다. 이 칸이 없으면 도는 트윈 대부분이 시뮬인 서버의 활동이 어느 추이에도 * 남지 않는다(계기는 지금만 답하고 재기동에 0 으로 돌아간다). * * 두 수를 따로 세는 이유: 2026-08-20 에 고친 것이 **행당 왕복**이다(델타마다 한 번 → 창당 한 번). * 행만 세면 그 개선도, 되돌아가는 것도 보이지 않는다. */ @Column({ type: 'int', default: 0 }) @Field(type => Int, { description: 'Journal rows written for this twin during this window. This is a different axis from ingest: a twin driven by a simulator source receives nothing (offered = 0) yet writes what it produces, so without this column its activity appears in no trend at all.' }) journaledRows: number @Column({ type: 'int', default: 0 }) @Field(type => Int, { description: 'Number of journal write round-trips (flushes) behind those rows. Counted separately from the rows themselves so that a regression back to per-delta writes — the defect fixed on 2026-08-20, where each kernel delta cost one insert — is visible as rows-per-flush falling back towards 1.' }) journalFlushes: number @CreateDateColumn() @Field({ nullable: true, description: 'When this record was written.' }) createdAt?: Date }