콘텐츠로 이동

채널과 상태

상태는 변수가 아니라 채널이에요

섹션 제목: “상태는 변수가 아니라 채널이에요”

워크플로우에는 “변수”가 없어요. 같은 메커니즘 위에 지어진 팀도 마찬가지예요. 대신 채널이 있어요. 채널은 값 타입과 업데이트 타입, 그리고 들어온 업데이트를 현재 값에 접어 넣는 리듀서를 가진, 이름이 붙은 슬롯이에요:

export interface Channel<V, U = V> {
readonly "~value": V;
readonly "~update": U;
readonly reduce: (current: V, update: U) => V;
readonly initial: () => V;
}

VU는 서로 다를 수 있어요. 예를 들어 listChannel의 값 타입은 readonly T[]지만, 업데이트로는 새 아이템 하나를 건네도 되고 배치를 통째로 건네도 돼요. 노드는 상태를 직접 바꾸지 않고 업데이트만 반환하며, 런타임이 그 업데이트를 해당 채널의 reduce로 접어 넣어요. 그래서 이 엔진 전체는 **state = fold(reduce, log, initial)**이라는 불변식 하나로 설명돼요. 라우터가 읽는 실시간 상태는 이 fold의 결과를 캐시해 둔 것일 뿐이고, 그 밑에 깔린 추가 전용 이벤트 로그가 유일하게 진짜인 권위예요. 이 불변식 덕분에 런타임이 생긴 뒤에는 이벤트 소싱 리플레이도 가능해져요. 로그를 리플레이한다는 건 결국 같은 fold를 다시 실행하는 것뿐이니까요.

export function lastChannel<T>(init: T): Channel<T, T> {
return { /* ... */ reduce: (_c, u) => u, initial: () => init };
}
export function listChannel<T>(): Channel<readonly T[], T | readonly T[]> {
return {
/* ... */
reduce: (c, u) => (Array.isArray(u) ? [...c, ...u] : [...c, u as T]),
initial: () => [],
};
}
생성자 리듀서 쓰임새
lastChannel(init) 덮어쓰기 — 가장 최근 업데이트가 이겨요 매번 교체되는 결과, 예: 가장 최신 리뷰 판정
listChannel() 추가 — 아이템 하나씩 또는 배치로 계속 쌓여요 계속 쌓이는 로그, 예: 대화 트랜스크립트
inputChannel<T>() 덮어쓰기지만 정적 초기값이 없어요 — 실행의 실제 입력 인자로 채워져요 실행 자체의 입력, 예: 팀이 트리아지하는 이슈

inputChannelteam()에서 써요. 값과 업데이트 레벨에서는 lastChannel과 형태가 같지만, 브랜드가 붙어 있어요(readonly "~input": true). 이 브랜드 덕분에 타입 시스템이 팀의 일반 도메인 채널과 실행 입력을 구성하는 채널을 정확히 구분해서 골라낼 수 있어요:

export interface InputChannel<T> extends Channel<T, T> {
readonly "~input": true;
}
export function inputChannel<T>(): InputChannel<T> { /* ... */ }
export type TeamInputOf<State> = {
readonly [K in keyof State as State[K] extends InputChannel<any> ? K : never]:
State[K] extends InputChannel<infer T> ? T : never;
};

채널들의 레코드가 주어지면, StateOf는 라우터가 실제로 보게 되는 평범한 객체 형태를 알려줘요. 채널 래퍼가 아니라 각 채널의 타입만 남긴 형태로요:

export type StateOf<C> = { readonly [K in keyof C]: C[K] extends Channel<infer V, any> ? V : never };
const state = {
figma: lastChannel<FigmaData | null>(null),
build: lastChannel<{ ok: boolean } | null>(null),
};
// StateOf<typeof state> = { readonly figma: FigmaData | null; readonly build: { ok: boolean } | null }

workflow() 라우터의 매개변수는 정확히 선언된 상태의 StateOf<...>예요. 그래서 s.build?.ok는 진짜로 좁혀지고 오타까지 검사되는 프로퍼티 접근이지, 범용 가방을 문자열로 뒤지는 게 아니에요.