/* * **원본이 준 레코드를 트윈에 넣는다** — 밀어 받는 길과 backfill 이 같이 쓴다. * * ── 왜 한 곳에 두나 ───────────────────────────────────────────────────────── * 이 몸통이 `routes.ts` 의 훅 안에 박혀 있었습니다. backfill 문을 만들면서 복사하면 두 길이 서로 * 다른 규칙으로 받게 되고, 한쪽만 수정되는 날이 옵니다. * * 실제로 그 위험이 이 자리에 있습니다. 유입 장부의 네 갈래(앉음 · 되풀이 · 어휘 · 거절)를 * 2026-09-05 에 고쳤는데, 그때 이 몸통이 두 벌이었다면 한쪽 셈만 닫혔을 것입니다. * * ── 커널을 직접 부르지 않는 쪽이 넘겨받는다 ──────────────────────────────── * 훅 판정(`reference-hook.ts`)은 이 함수를 인자로 받습니다. 그 파일이 엔진을 직접 부르면 테스트할 * 때 엔진이 필요해집니다. 그 규율은 그대로 두고, 이 파일이 엔진을 아는 유일한 자리가 됩니다. */ import type { IngestResult } from './reference-hook.js' import { TwinEngine } from '../../engine/index.js' import { ingestCanonicalRecords } from '../../engine/canonical-ingest.js' import { databaseCommandStore, settleEffects } from '../actuation/index.js' import { shouldHaveTime } from '../../engine/event-time.js' import { twinWarn } from '../../engine/log.js' /** * 레코드를 넣고 **네 갈래로 답한다.** * * 두 갈래로는 셈이 안 닫힙니다(인티그레이션 레인 실측 2026-09-05). * * ``` * 두 갈래 157 − 46 − 2 = 109 「설명 못 함」 진실은 되풀이 108 + 어휘 1 * 네 갈래 157 − 46 − 2 − 108 − 1 = 0 * ``` * * 109 를 「사라졌다」로 내면 그날부터 아무도 그 수를 안 봅니다. 늘 참인 경고는 벽지가 됩니다. * * `vocabulary` 는 저널에 안 가는 어휘 요소의 수입니다. 사라진 것이 아닙니다. * * @param via 유입 장부에 남길 길 이름 — `hook`(밀어 받음) · `fill`(빠진 구간을 다시 받음) */ export function liveIngest( domainId: string, instanceId: string, records: unknown[], via: string ): IngestResult & { vocabulary: number; undated?: Record } { const { accepted, rejected, masterData, undated } = ingestCanonicalRecords( records as any, domainId, new Date().toISOString(), TwinEngine.factScope(domainId, instanceId) ) /* 사건이 아닌 것은 상태만 세운다 — 두 길이 같은 규율이다(§`applyMasterData`). */ if (masterData.length) TwinEngine.applyMasterData(domainId, instanceId, masterData) const { applied, duplicates } = TwinEngine.ingestLiveResult(domainId, instanceId, accepted, via) /* * **나간 지시와 잇는다** — 폐루프. 기다리지 않습니다: 훅의 응답이 이것 때문에 늦으면 보내는 쪽이 * 되풀이하고, 그러면 같은 관측이 두 번 옵니다. 실패해도 관측 유입은 멈추지 않습니다. */ void settleEffects({ domainId, envelopes: accepted, store: databaseCommandStore() }) /* * 커널이 받지 못한 수를 이름으로 남깁니다 — 트윈이 멈춘 사이 버려진 레코드를 「통과」로 세면 * 화면이 100% 라고 말합니다. 중복으로 걸러진 것은 버려진 것이 아니므로 빼고 셉니다. */ const undelivered = Math.max(0, accepted.length - applied - duplicates) TwinEngine.recordIngestResult(domainId, instanceId, records.length, rejected, Date.now(), undelivered, masterData.length) /* * 떨어진 것을 **수가 아니라 목록으로** 넘깁니다 — 응답에 실어야 보내는 쪽이 그것만 따로 둘 수 * 있습니다. 수만 주면 배치 전체를 다시 보내거나 전부 버리는 두 갈래밖에 없습니다. */ /* * **표에 있는 type 인데 시각을 못 읽었으면 그것은 결함이다.** 그 fact 는 자기 시각을 싣기로 되어 * 있는데 안 실려 온 것이고, 그대로 두면 ingest 시각이 그 사실의 시각으로 저장된다. * * 표에 없는 type 은 세기만 한다 — 절대값 snapshot 은 polling 순간이 맞는 시각이다. */ const wrong = Object.entries(undated ?? {}).filter(([type]) => shouldHaveTime(type)) if (wrong.length) { twinWarn( `[twin-ingest] "${instanceId}" — 시각을 실어야 하는 fact 가 시각 없이 왔습니다: ` + wrong.map(([t, n]) => `${t} ${n}건`).join(' · ') + `. 그 사실들은 지금 시각으로 저널에 앉습니다` ) } return { applied, duplicates, vocabulary: masterData.length, rejected, ...(undated && Object.keys(undated).length ? { undated } : {}) } }