/* * 커맨드가 저장소에 닿는 두 자리 — 찾는 것과 적는 것. * * 디스패처가 엔티티를 직접 가져오면 그 파일을 부르는 시험이 TypeORM 데코레이터를 지나게 되고, 시험이 * 서지 않는다. 그러면 **게이트의 배선을 아무 시험도 걷지 않게 된다** — 훅에서 이미 겪은 부류다 * (§`hook-store.ts`). */ import { In, IsNull, Not } from 'typeorm' import { getRepository } from '@things-factory/shell' import type { TwinCommand } from '@operato/ops-contract' import { hydratedDate } from '../../engine/read-time.js' import { TwinCommandRecord } from './twin-command.js' import type { CommandStore, StoredCommand } from './command-dispatcher.js' /** * 행 → 커맨드. 상태 어휘는 계약이 정한 문자열 그대로다. * * `createdAt` 은 드라이버가 준 값이라 **직접 날짜로 만들지 않는다** — 드라이버마다 모양이 다르고, * 문자열로 오는 드라이버에서는 시간대가 없는 값이 된다. `hydratedDate` 가 그 컬럼의 정의를 보고 푼다. */ function toCommand(repo: any, row: TwinCommandRecord): StoredCommand { return { ...(row.approvalRef ? { approvalRef: row.approvalRef } : {}), ...(row.proposedBy ? { proposedBy: row.proposedBy } : {}), ...(row.causeRef ? { causeRef: row.causeRef } : {}), ...(row.ruleRef ? { ruleRef: row.ruleRef } : {}), id: row.id, instanceId: row.instanceId, type: row.type, ...(row.payload ? { payload: row.payload } : {}), origin: row.origin as TwinCommand['origin'], proposedAt: hydratedDate(repo, 'createdAt', row.createdAt)?.toISOString() ?? '', /* 없는 것을 거짓으로 채우지 않는다 — 비어 있음이 「되돌릴 수 없다」의 뜻이다. */ ...(row.reversible === null || row.reversible === undefined ? {} : { reversible: row.reversible }), state: row.state as TwinCommand['state'], ...(row.decidedBy ? { approval: { by: row.decidedBy, at: row.decidedAt ?? '', ...(row.decisionNote ? { note: row.decisionNote } : {}) } } : {}), ...(row.dispatchRef ? { dispatchRef: row.dispatchRef } : {}), ...(row.dispatchNote ? { dispatchNote: row.dispatchNote } : {}), /* 셋이 다 있어야 효과다 — 하나라도 비면 적지 않는다(빈 값을 사실로 만들지 않는다). */ ...(row.effectStatus && row.effectAt ? { effect: { status: row.effectStatus, at: row.effectAt, seen: row.effectSeen ?? 1 } } : {}), ...(row.error ? { error: row.error } : {}) } } /** * 실제 저장소를 쓰는 구현 — 리졸버가 이것을 넣는다. * * **트랜잭션을 받는다.** `getRepository(target)` 를 인자 없이 부르면 요청의 트랜잭션에 붙지 않고 * 별도로 커밋된다(`shell/initializers/database.ts:47`). 그러면 「올리고 적기」가 한 덩어리가 아니게 되고, * 뒤가 실패했을 때 앞이 남는다 — 결재선에는 없는데 커맨드는 적혀 직접 결정이 열린 상태다. * * 콜백에서 부를 때도 넘긴다 — 업무 목록의 판정과 우리 커맨드가 같이 커밋되거나 같이 물러나야 한다. */ export function databaseCommandStore(tx?: any): CommandStore { return { load: async (domainId: string, id: string) => { const repo = await getRepository(TwinCommandRecord, tx) const row = await repo.findOne({ where: { domain: { id: domainId }, id } }).catch(() => null) return row ? toCommand(repo, row) : null }, /** * 결정을 기다리는 것들의 **근거** — 규칙이 같은 조건을 두 번 올리지 않게. * * 저장소에서 읽는다. 프로세스 기억에 두면 재기동이 곧 중복이 된다 — 다시 선 트윈이 같은 조건을 처음 * 보는 것으로 읽고 대기 줄에 같은 지시를 또 올린다. * * `proposed` 만 본다 — 결정을 기다리는 것은 시간과 무관하게 막는다. 결정이 끝난 것을 언제 다시 낼지는 * 이것이 아니라 **기다리는 시간**이 정한다(§`lastProposedAt`). */ openCauses: async (domainId: string, instanceId: string) => { const repo = await getRepository(TwinCommandRecord, tx) const rows = await repo .find({ where: { domain: { id: domainId }, instanceId, state: 'proposed' }, select: ['causeRef'] as any }) .catch(() => [] as TwinCommandRecord[]) return new Set(rows.map(r => r.causeRef).filter((v): v is string => !!v)) }, /** * 근거마다 **마지막으로 조치를 낸 시각** — 결정이 끝난 것까지 본다. * * `openCauses` 만으로는 부족하다. 승인하는 순간 그 커맨드가 대기에서 빠지고 조건은 그대로 서 있어서 * 다음 바퀴가 같은 것을 또 낸다. 실제로 그렇게 됐다 — 승인 한 번에 대기 줄이 다시 찼다. */ lastProposedAt: async (domainId: string, instanceId: string) => { const repo = await getRepository(TwinCommandRecord, tx) const rows = await repo .find({ where: { domain: { id: domainId }, instanceId }, select: ['causeRef', 'createdAt'] as any }) .catch(() => [] as TwinCommandRecord[]) const out = new Map() for (const row of rows) { if (!row.causeRef) continue /* 드라이버가 준 값을 직접 날짜로 만들지 않는다 — 컬럼 정의를 보고 푼다. */ const at = hydratedDate(repo, 'createdAt', row.createdAt)?.getTime() if (at === undefined || !Number.isFinite(at)) continue const prev = out.get(row.causeRef) if (prev === undefined || at > prev) out.set(row.causeRef, at) } return out }, /** * 넘긴 지시의 식별자로 찾는다 — 한 번에 묶어서 묻는다. * * 유입은 초당 수천 건이 지나는 자리다. 관측 하나마다 질의를 붙이면 그 경로가 느려지고, 느려지면 * 훅이 밀린다. 그래서 묶음 하나에 질의 하나다. */ byDispatchRef: async (domainId: string, refs: readonly string[]) => { const out = new Map() if (!refs.length) return out const repo = await getRepository(TwinCommandRecord, tx) const rows = await repo .find({ where: { domain: { id: domainId }, dispatchRef: In([...new Set(refs)]) } }) .catch(() => [] as TwinCommandRecord[]) for (const row of rows) { if (row.dispatchRef) out.set(row.dispatchRef, toCommand(repo, row)) } return out }, /** * 효과를 기다리는 조치들 — 넘긴 식별자가 있는 것만. * * 효과가 이미 적힌 것도 낸다. 상태는 여러 번 바뀌고(초안 → 나감 → 진행 중), 마지막 것이 남아야 * 화면이 「지금 저쪽에서 무엇인가」에 답할 수 있다. * * 결정이 안 끝난 것과 실패한 것은 빼지 않는다 — 실패로 적혔는데 저쪽은 받았을 수 있고, 그때 * 식별자가 있으면 그 사실이 이 길로 드러난다. */ awaitingEffect: async (domainId: string) => { const repo = await getRepository(TwinCommandRecord, tx) const rows = await repo .find({ where: { domain: { id: domainId }, dispatchRef: Not(IsNull()) } }) .catch(() => [] as TwinCommandRecord[]) return rows.map(row => toCommand(repo, row)) }, /* * 넘길 차례를 기다리는 것들 — **두 상태만** 본다. * * `approved` 는 승인 직후 발신이 놓친 것(프로세스가 그 사이에 죽었다), `failed` 는 다시 해 * 볼 것이다. 그 밖의 상태는 일꾼의 일이 아니다 — `proposed` 는 승인을 기다리고 `acked` 는 * 종착이다. * * 물러섬은 여기서 걸러내지 않는다. 걸러내려면 시각 비교를 SQL 방언으로 써야 하고(이 저장소는 * 다섯 드라이버를 지난다), 판정은 이미 순수 함수가 한다(§`retryDecisionOf`). 대기 줄이 큰 * 현장이 오면 그때 인덱스와 함께 다시 본다. */ pendingDispatch: async (domainId: string) => { const repo = await getRepository(TwinCommandRecord, tx) const rows = await repo .find({ where: { domain: { id: domainId }, state: In(['approved', 'failed']) } }) .catch(() => [] as TwinCommandRecord[]) return rows.map(row => toCommand(repo, row)) }, /* * 효과만 적는다 — 상태·승인·오류는 건드리지 않는다. 관측이 조치의 수명에 손을 대는 길은 두지 * 않는다. 그리고 도메인으로 좁힌다 — 식별자는 시스템마다 겹칠 수 있다. */ recordEffects: async (domainId: string, updates) => { const repo = await getRepository(TwinCommandRecord, tx) for (const u of updates) { await repo.update( { id: u.commandId, domain: { id: domainId } }, { effectStatus: u.status, effectAt: u.at, effectSeen: u.seen } as any ) } }, /* * **근거는 여기서 안 적는다.** `causeRef`·`ruleRef` 는 낼 때 정해지고 바뀌지 않는다. 이 목록에 넣으면 * 상태가 바뀔 때마다 다시 쓰이고, 부르는 쪽이 그 칸을 빼먹은 날 **근거가 지워진다** — 그러면 중복 * 판정이 그 커맨드를 못 보고 같은 지시를 또 올린다. */ save: async (domainId: string, command: StoredCommand) => { await getRepository(TwinCommandRecord, tx).update( { id: command.id, domain: { id: domainId } }, { state: command.state, approvalRef: command.approvalRef ?? null, decidedBy: command.approval?.by ?? null, decidedAt: command.approval?.at ?? null, decisionNote: command.approval?.note ?? null, dispatchRef: command.dispatchRef ?? null, dispatchNote: command.dispatchNote ?? null, dispatchNoteNext: command.dispatchNoteNext ?? null, error: command.error ?? null, /* 일꾼이 쓰는 셋 — 없으면 물러섬이 표에 안 남고 재기동마다 즉시 재시도가 된다. */ retry: command.retry ?? null, attempts: command.attempts ?? null, nextAttemptAt: command.nextAttemptAt ?? null } as any ) }, /* * 낼 때는 커맨드가 든 것을 그대로 적는다. * * `proposedAt` 은 옮기지 않는다 — 그 값은 `createdAt` 에서 읽으므로(§`toCommand`), 여기서 또 적으면 * 같은 사실이 두 칸에 살고 하나만 고쳐지는 날이 온다. 그리고 되돌려 줄 때는 **적힌 행을 다시 읽는다** * — 낸 값을 그대로 답하면 드라이버가 실제로 무엇을 적었는지 모른 채로 사람에게 보여 준다. */ create: async (domainId: string, command: StoredCommand) => { const repo = await getRepository(TwinCommandRecord, tx) const saved = await repo.save({ id: command.id, domain: { id: domainId }, instanceId: command.instanceId, type: command.type, payload: command.payload ?? null, origin: command.origin, proposedBy: command.proposedBy ?? null, causeRef: command.causeRef ?? null, ruleRef: command.ruleRef ?? null, reversible: command.reversible ?? null, state: command.state, approvalRef: command.approvalRef ?? null } as any) const row = await repo.findOne({ where: { domain: { id: domainId }, id: saved.id } }) if (!row) throw new Error(`커맨드를 적었는데 다시 읽을 수 없다 (${saved.id})`) return toCommand(repo, row) } } }