/* * **레코드가 말하는 「언제 일어났나」를 읽는다** — fact 종류마다 칸 이름이 다르다. * * ── 같은 defect 가 세 번째다 ──────────────────────────────────────────────── * `canonical-ingest.ts` 가 `eventTime ?? at` **두 이름만** 보고 있었다. 그 주석에 이미 두 번의 사고가 * 적혀 있다 — 처음엔 `eventTime` 하나만 봐서 energy connector 의 사실 18,150건이 polling 시각으로 * 찍혔고, 그 다음에 `at` 을 더했다. * * 세 번째는 `nonconformance.disposition` 이었다. * * ``` * [측정함 2026-09-06, mes-line-a backfill 뒤] * event_time 2026-09-05 23:50:51 payload decidedAt 2026-09-01T02:50:51.832Z 5일 차이 * ``` * * ── root cause: 선언과 봉투가 다른 이름을 쓴다 ────────────────────────────── * 운영 레코드의 봉투 시각은 `at` 이다(`OperationalRecordEnvelopeFields`). 그런데 처분 fact 는 자기 * 시각을 **payload 의 `decidedAt`** 에 싣는다 — contract 가 그 자리에 required 로 선언했고 plant 도 * 맞게 보낸다. 읽는 쪽에 그 이름이 없어서 `defaultEventTime`(= ingest 시각)으로 떨어졌다. * * ── push 경로에서는 안 보인다 ─────────────────────────────────────────────── * 사실이 나자마자 보내면 ingest 시각과 event 시각이 몇 초 차이다. backfill 이 그 간격을 벌려서 * 드러냈다 — **backfill 이 만든 defect 가 아니라 원래 있던 것이 보인 것**이다. * * ── 이름을 늘리는 것으로는 안 끝난다 ──────────────────────────────────────── * 다음 fact 종류가 또 다른 이름을 쓰면 같은 자리를 밟는다. 그래서 두 가지를 함께 한다. * * ``` * 1 종류마다의 칸 이름을 한 표에 둔다 새 종류를 더할 때 여기가 눈에 띈다 * 2 못 읽으면 그 수를 낸다 조용히 ingest 시각으로 떨어지지 않는다 * ``` * * 종류 판정은 **contract 의 것을 부른다**(`operationalKindOf`). 여기서 다시 짐작하면 두 곳이 다른 * 답을 내고, 그때 어느 쪽이 맞는지 아무도 모른다. */ import { operationalKindOf } from '@operato/ops-contract' /** * 운영 fact 종류가 자기 시각을 싣는 칸 — 봉투의 `at` 이 없을 때 읽는다. * * 새 종류가 자기 시각을 payload 에 실으면 여기 한 줄을 더한다. */ export const KIND_TIME_FIELD: Record = { /* `DispositionFact.decidedAt` — contract 가 required 로 선언했다. */ disposition: 'decidedAt' } /* * `equipment-period` 는 일부러 안 넣는다. 그 fact 의 봉투 시각은 **구간의 끝**이고(그때 성립한다), * `recordTime` 은 「그 구간을 언제 적었나」다. 둘이 갈려 있어야 늦게 적은 구간이 늦게 일어난 것으로 * 읽히지 않는다 — contract 가 그 판단을 이미 적어 두었다. */ function iso(v: unknown): string | undefined { return typeof v === 'string' && v.trim() ? v : undefined } /** * 이 레코드가 말하는 시각. **못 읽으면 `undefined`** — 여기서 지금 시각으로 채우지 않는다. * * 보는 순서는 공통 이름이 먼저다. connector 가 봉투 층에서 시각을 실어 주면 그것이 그 레코드에 대한 * 가장 바깥의 판단이고, 종류마다의 칸은 그것이 없을 때 읽는다. */ export function factEventTime(record: unknown): string | undefined { const r = record as any const common = iso(r?.eventTime) ?? iso(r?.at) if (common) return common const kind = operationalKindOf(record) const named = kind ? KIND_TIME_FIELD[kind] : undefined return named ? iso(r?.[named]) : undefined } /** * 시각을 못 읽은 레코드를 종류별로 센다 — **조용히 ingest 시각으로 떨어진 것들.** * * 오류가 아니다. 절대값 snapshot 관측은 원본의 갱신 시각이 아니라 polling 순간이 맞는 시각이라 * (「어제 값을 오늘 봤다」는 「어제 일어났다」가 아니다) 여기 세어지는 것이 정상이다. * * 다만 그 수가 어디에도 안 남으면 `event_time == created_at` 이 유일한 단서가 되고, 그것은 우연히 * 같은 것과 구별되지 않는다. */ export function undatedByKind(records: readonly unknown[]): Record { const out: Record = {} for (const r of records) { if (factEventTime(r) !== undefined) continue const kind = operationalKindOf(r) ?? ((r as any)?.type as string) ?? '(종류 모름)' out[kind] = (out[kind] ?? 0) + 1 } return out } /** 표에 있는 종류인데 시각을 못 읽었다 — 그것은 실제로 결함이다. */ export function shouldHaveTime(kind: string): boolean { return kind in KIND_TIME_FIELD }